From 5c39b9bd435d7ced9497dea7003313172b8a7523 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Wed, 15 Oct 2025 01:19:24 +0100 Subject: [PATCH 01/48] test(auth): add signup validation tests (missing fields, invalid email, no password) --- jest.config.js | 8 + package-lock.json | 7312 +++++++++++++++++++++++----- package.json | 10 +- src/app.ts | 3 + src/tests/auth/signup.test.ts | 57 + src/tests/fixtures/organization.ts | 5 + src/tests/fixtures/user.ts | 20 + src/tests/setup.ts | 24 + tsconfig.json | 7 +- 9 files changed, 6090 insertions(+), 1356 deletions(-) create mode 100644 jest.config.js create mode 100644 src/tests/auth/signup.test.ts create mode 100644 src/tests/fixtures/organization.ts create mode 100644 src/tests/fixtures/user.ts create mode 100644 src/tests/setup.ts diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..0351933 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,8 @@ + +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/tests/**/*.test.ts"], + clearMocks: true, + setupFilesAfterEnv: ["/src/tests/setup.ts"], +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 990ac22..973e8d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,1362 +21,3667 @@ "@eslint/js": "^9.37.0", "@types/cors": "^2.8.19", "@types/express": "^4.17.21", + "@types/jest": "^30.0.0", + "@types/supertest": "^6.0.3", "eslint": "^9.37.0", + "jest": "^30.2.0", + "mongodb-memory-server": "^10.2.3", + "supertest": "^7.1.4", + "ts-jest": "^29.4.5", "ts-node-dev": "^2.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.46.1" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=12" + "node": ">=6.9.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=6.9.0" }, "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "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==", + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "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==", + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=6.9.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==", + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "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" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", - "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/core": "^0.16.0" + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "Apache-2.0", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "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" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@eslint/js": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", - "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=6.9.0" } }, - "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==", + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.16.0", - "levn": "^0.4.1" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.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==", + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=18.18.0" + "node": ">=6.9.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { - "node": ">=18.18.0" + "node": ">=6.9.0" } }, - "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==", + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.0.0" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz", - "integrity": "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg==", + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, "license": "MIT", "dependencies": { - "sparse-bitfield": "^3.0.3" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "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==", + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@babel/helper-plugin-utils": "^7.10.4" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "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==", + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "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==", + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">= 8" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "license": "MIT", "dependencies": { - "@types/connect": "*", - "@types/node": "*" + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "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", - "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, "license": "MIT", "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", - "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.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==", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/morgan": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", - "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", "license": "MIT", "dependencies": { - "@types/node": "*" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@types/node": { - "version": "24.7.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", - "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.14.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true, "license": "MIT" }, - "node_modules/@types/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", - "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" } }, - "node_modules/@types/serve-static": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", - "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "node_modules/@emnapi/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" } }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/mime": "^1", - "@types/node": "*" + "tslib": "^2.4.0" } }, - "node_modules/@types/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/strip-json-comments": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", - "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", "license": "MIT", + "optional": true, "dependencies": { - "@types/webidl-conversions": "*" + "tslib": "^2.4.0" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", - "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/type-utils": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "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==", + "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": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">= 4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", - "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "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": { - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4" + "@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" - }, - "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/project-service": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", - "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" + "@eslint/core": "^0.16.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", - "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "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": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" + "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": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "node_modules/@eslint/js": { + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", "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" + "url": "https://eslint.org/donate" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", - "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", + "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": "MIT", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@eslint/core": "^0.16.0", + "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.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "engines": { + "node": ">=18.18.0" + } + }, + "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" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", - "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "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": "MIT", + "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "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" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "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==", + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "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==", + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", - "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1" + "ansi-regex": "^6.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", - "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "eslint-visitor-keys": "^4.2.1" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "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", + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=8" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" + "dependencies": { + "sprintf-js": "~1.0.2" } }, - "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==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "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/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.11.0" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, - "engines": { - "node": ">=0.4.0" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "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" + "p-locate": "^4.1.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=8" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "p-try": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=6" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "p-limit": "^2.2.0" }, "engines": { - "node": ">= 8" + "node": ">=8" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "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/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, - "license": "MIT" - }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.1.2" - }, "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, "license": "MIT", "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "node_modules/@jest/console": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/@jest/core": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "2.0.0" + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.20.1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", "dev": true, - "license": "MIT" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, "engines": { - "node": ">= 0.8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "@jest/get-type": "30.1.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "@types/node": "*", + "jest-regex-util": "30.0.1" }, "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@jest/reporters": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "engines": { - "node": ">= 6" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@jest/reporters/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/@jest/reporters/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" + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", + "node_modules/@jest/reporters/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": { - "safe-buffer": "5.2.1" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">= 0.6" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, "engines": { - "node": ">= 0.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, "engines": { - "node": ">= 0.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, "license": "MIT", "dependencies": { - "object-assign": "^4", - "vary": "^1" + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" }, "engines": { - "node": ">= 0.10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "node_modules/@jest/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/@jest/test-result": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" }, "engines": { - "node": ">= 8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@jest/test-sequencer": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "slash": "^3.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "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==", + "node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", "dev": true, - "license": "MIT" - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, "engines": { - "node": ">= 0.8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/@jest/transform/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, "engines": { - "node": ">=0.3.1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/dynamic-dedupe": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", - "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "dependencies": { - "xtend": "^4.0.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=6.0.0" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/@mongodb-js/saslprep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz", + "integrity": "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" + "sparse-bitfield": "^3.0.3" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } }, - "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==", + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": "^14.21.3 || >=16" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/eslint": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", - "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "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": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.4.0", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.37.0", + "@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/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.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/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.14.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", + "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", + "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", + "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/type-utils": "8.46.1", + "@typescript-eslint/utils": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.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-eslint/parser": "^8.46.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "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", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", + "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "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/project-service": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", + "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.1", + "@typescript-eslint/types": "^8.46.1", + "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/scope-manager": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", + "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1" + }, + "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.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", + "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "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.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", + "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1", + "@typescript-eslint/utils": "8.46.1", + "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/types": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", + "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "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.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", + "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.1", + "@typescript-eslint/tsconfig-utils": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/visitor-keys": "8.46.1", + "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/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/utils": { + "version": "8.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", + "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.1", + "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/typescript-estree": "8.46.1" + }, + "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.46.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", + "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.1", + "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/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "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/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.0.tgz", + "integrity": "sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", + "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001750", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", + "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", + "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.235", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.235.tgz", + "integrity": "sha512-i/7ntLFwOdoHY7sgjlTIDo4Sl8EdoTjWIaKinYOVfC6bOp71bmwenyZthWHcasxgHDNWbWxvG9M3Ia116zIaYQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -1386,713 +3691,1935 @@ "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" + "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/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/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "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-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "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/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "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", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "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/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/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/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/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/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/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "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/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" }, "bin": { - "eslint": "bin/eslint.js" + "handlebars": "bin/handlebars" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://eslint.org/donate" + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" }, - "peerDependencies": { - "jiti": "*" + "engines": { + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "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/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-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=0.10.0" } }, - "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==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=0.12.0" } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=8" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, - "license": "BSD-2-Clause", + "license": "BSD-3-Clause", "dependencies": { - "estraverse": "^5.2.0" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=4.0" + "node": ">=10" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "BSD-2-Clause", + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=4.0" + "node": ">=10" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, - "license": "BSD-2-Clause", + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/istanbul-lib-source-maps/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "license": "MIT", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, "engines": { - "node": ">= 0.10.0" + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "2.0.0" + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/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==", + "node_modules/jest-changed-files": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "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" + "execa": "^5.1.1", + "jest-util": "30.2.0", + "p-limit": "^3.1.0" }, "engines": { - "node": ">=8.6.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/jest-circus": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "p-limit": "^3.1.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" }, "engines": { - "node": ">= 6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/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" + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } }, - "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==", + "node_modules/jest-config/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "node_modules/jest-config/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": { - "reusify": "^1.0.4" + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "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==", + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" }, "engines": { - "node": ">=16.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "detect-newline": "^3.1.0" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "node_modules/jest-each": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": ">= 0.8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/jest-environment-node": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "2.0.0" + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/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==", + "node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "fsevents": "^2.3.3" } }, - "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==", + "node_modules/jest-leak-detector": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "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==", + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, - "license": "ISC" - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, "engines": { - "node": ">= 0.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, "engines": { - "node": ">= 0.6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=6" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/jest-resolve": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "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==", + "node_modules/jest-resolve-dependencies": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" }, "engines": { - "node": ">=10.13.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/jest-runner": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "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": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/jest-runtime": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">= 0.8" + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", + "node_modules/jest-runtime/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": { - "safer-buffer": ">= 2.1.2 < 3" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/jest-runtime/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=8" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/jest-snapshot": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, "engines": { - "node": ">=0.8.19" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/jest-validate": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.2.0" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/jest-watcher": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.2.0", + "string-length": "^4.0.2" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=0.12.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "ISC" + "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -2107,6 +5634,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2114,6 +5654,13 @@ "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", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "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", @@ -2128,6 +5675,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/kareem": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", @@ -2147,6 +5707,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2161,6 +5731,13 @@ "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", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2177,6 +5754,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "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", @@ -2184,6 +5768,32 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -2191,6 +5801,16 @@ "dev": true, "license": "ISC" }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2224,6 +5844,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2290,6 +5917,16 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2313,6 +5950,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -2382,6 +6029,58 @@ "whatwg-url": "^14.1.0 || ^13.0.0" } }, + "node_modules/mongodb-memory-server": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.2.3.tgz", + "integrity": "sha512-/ZNXX2IwmEXErt3HGgJCxYqmfS3thDG5W3cdoMBsC55U/PPzGoAFcdUruvP88uOETLZBBpLbNaWdk2LlinyRlg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "10.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.2.3.tgz", + "integrity": "sha512-a6OMGO2xJDvxCxNpRpxmy4roMGXQQRIB/2uZyoCWRdDQBIr4o6KMhRzWV5PTrKPk+BavQ+W46j4S1Dj4PpUGXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.1", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.15.9", + "https-proxy-agent": "^7.0.6", + "mongodb": "^6.9.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.2", + "tar-stream": "^3.1.7", + "tslib": "^2.8.1", + "yauzl": "^3.2.0" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mongoose": { "version": "8.19.1", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.19.1.tgz", @@ -2474,6 +6173,22 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2481,15 +6196,49 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/new-find-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, "engines": { - "node": ">= 0.6" + "node": ">=12.22.0" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "dev": true, + "license": "MIT" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -2500,6 +6249,19 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2552,6 +6314,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2602,6 +6380,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2615,6 +6410,25 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2661,12 +6475,50 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2680,6 +6532,85 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2690,6 +6621,34 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2712,6 +6671,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -2772,6 +6748,13 @@ "node": ">= 0.8" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2785,6 +6768,16 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -2806,6 +6799,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3074,6 +7090,23 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3101,16 +7134,130 @@ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "license": "MIT", "dependencies": { - "memory-pager": "^1.0.2" + "memory-pager": "^1.0.2" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">= 0.8" + "node": ">=8" } }, "node_modules/strip-bom": { @@ -3123,6 +7270,16 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3136,6 +7293,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3162,6 +7367,66 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3219,6 +7484,72 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-jest": { + "version": "29.4.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", + "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -3321,6 +7652,13 @@ "node": ">=0.10.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3334,6 +7672,29 @@ "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", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -3385,6 +7746,20 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", @@ -3400,6 +7775,72 @@ "node": ">= 0.8" } }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "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", @@ -3426,6 +7867,32 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3435,6 +7902,16 @@ "node": ">= 0.8" } }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -3483,6 +7960,50 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3490,6 +8011,33 @@ "dev": true, "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -3500,6 +8048,66 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", + "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 98711cd..ee9783f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "build": "tsc", "start": "node dist/server.js", "lint": "eslint . --ext .ts --fix", - "lint:check": "eslint . --ext .ts" + "lint:check": "eslint . --ext .ts", + "test": "jest --runInBand --detectOpenHandles", + "test:watch": "jest --watch" }, "dependencies": { "@types/morgan": "^1.9.10", @@ -24,7 +26,13 @@ "@eslint/js": "^9.37.0", "@types/cors": "^2.8.19", "@types/express": "^4.17.21", + "@types/jest": "^30.0.0", + "@types/supertest": "^6.0.3", "eslint": "^9.37.0", + "jest": "^30.2.0", + "mongodb-memory-server": "^10.2.3", + "supertest": "^7.1.4", + "ts-jest": "^29.4.5", "ts-node-dev": "^2.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.46.1" diff --git a/src/app.ts b/src/app.ts index a963033..5adf2e8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,5 +9,8 @@ app.use(cors()); app.use(express.json()); app.use(morgan("dev")); +app.get("/api/health", (req, res) => { + res.send({ status: "ok" }); +}); export default app; diff --git a/src/tests/auth/signup.test.ts b/src/tests/auth/signup.test.ts new file mode 100644 index 0000000..48f16ec --- /dev/null +++ b/src/tests/auth/signup.test.ts @@ -0,0 +1,57 @@ +import request from "supertest"; +import app from "../../../src/app"; +import { orgFixtures } from "../fixtures/organization"; +import { userFixtures } from "../fixtures/user"; + +describe("User Signup", () => { + it("should return 201 when a user signs up successfully", async () => { + const res = await request(app) + .post("/api/auth/signup") + .send({ + ...userFixtures.validOwner, + organizationName: orgFixtures.validOrg.name, + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("userId"); + }); + it("should return 400 if organizationName is missing", async () => { + const res = await request(app) + .post("/api/auth/signup") + .send({ + ...userFixtures.validOwner, + }); + + expect(res.status).toBe(400); + }); + it("should return 400 if email is missing", async () => { + const res = await request(app) + .post("/api/auth/signup") + .send({ + ...userFixtures.noEmail, + organizationName: orgFixtures.validOrg.name, + }); + + expect(res.status).toBe(400); + }); + it("should return 400 if password is missing", async () => { + const res = await request(app) + .post("/api/auth/signup") + .send({ + ...userFixtures.noPassword, + organizationName: orgFixtures.validOrg.name, + }); + + expect(res.status).toBe(400); + }); + it("should return 400 if email is invalid", async () => { + const res = await request(app) + .post("/api/auth/signup") + .send({ + ...userFixtures.invalidEmail, + organizationName: orgFixtures.validOrg.name, + }); + + expect(res.status).toBe(400); + }); +}); diff --git a/src/tests/fixtures/organization.ts b/src/tests/fixtures/organization.ts new file mode 100644 index 0000000..6234cf5 --- /dev/null +++ b/src/tests/fixtures/organization.ts @@ -0,0 +1,5 @@ +export const orgFixtures = { + validOrg: { + name: "Startup & Co.", + }, +}; diff --git a/src/tests/fixtures/user.ts b/src/tests/fixtures/user.ts new file mode 100644 index 0000000..e9bbab7 --- /dev/null +++ b/src/tests/fixtures/user.ts @@ -0,0 +1,20 @@ +export const userFixtures = { + validOwner: { + email: "owner@example.com", + password: "StrongPass123!", + name: "Test Owner", + }, + noPassword: { + email: "nopass@example.com", + name: "No Password User", + }, + noEmail: { + password: "StrongPass123!", + name: "Invalid Email User", + }, + invalidEmail: { + email: "not-an-email", + password: "StrongPass123!", + name: "Invalid Email User", + }, +}; diff --git a/src/tests/setup.ts b/src/tests/setup.ts new file mode 100644 index 0000000..eefea2e --- /dev/null +++ b/src/tests/setup.ts @@ -0,0 +1,24 @@ + +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import request from "supertest"; + +let mongo: MongoMemoryServer; + +beforeAll(async () => { + mongo = await MongoMemoryServer.create(); + const uri = mongo.getUri(); + await mongoose.connect(uri); +}); + +beforeEach(async () => { + const collections = await mongoose.connection.db?.collections(); + for (let collection of collections || []) { + await collection.deleteMany({}); + } +}); + +afterAll(async () => { + await mongoose.connection.close(); + if (mongo) await mongo.stop(); +}); diff --git a/tsconfig.json b/tsconfig.json index c578927..f0a08e5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2021", + "target": "ES2021", "module": "CommonJS", "rootDir": "src", "outDir": "dist", @@ -11,10 +11,11 @@ "esModuleInterop": true, "skipLibCheck": true, - "types": ["node"], + "types": ["node", "jest"], "sourceMap": true, "declaration": true, "declarationMap": true - } + }, + "include": ["src", "tests"] } From b8eb18022390f1e4999e9764a563a94809257654 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 17 Oct 2025 08:57:10 +0100 Subject: [PATCH 02/48] refactor: update folder structure to use modules --- jest.config.js | 8 --- jest.config.mjs | 21 ++++++ package.json | 1 + src/modules/auth/__tests__/signup.v1.test.ts | 72 ++++++++++++++++++++ src/tests/auth/signup.test.ts | 57 ---------------- src/tests/factories/organization.factory.ts | 9 +++ src/tests/factories/user.factory.ts | 10 +++ src/tests/fixtures/organization.ts | 6 +- src/tests/fixtures/user.ts | 21 +++--- src/tests/setup.ts | 7 +- tsconfig.json | 13 +++- 11 files changed, 138 insertions(+), 87 deletions(-) delete mode 100644 jest.config.js create mode 100644 jest.config.mjs create mode 100644 src/modules/auth/__tests__/signup.v1.test.ts delete mode 100644 src/tests/auth/signup.test.ts create mode 100644 src/tests/factories/organization.factory.ts create mode 100644 src/tests/factories/user.factory.ts diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 0351933..0000000 --- a/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ - -module.exports = { - preset: "ts-jest", - testEnvironment: "node", - testMatch: ["**/tests/**/*.test.ts"], - clearMocks: true, - setupFilesAfterEnv: ["/src/tests/setup.ts"], -}; \ No newline at end of file diff --git a/jest.config.mjs b/jest.config.mjs new file mode 100644 index 0000000..378dbee --- /dev/null +++ b/jest.config.mjs @@ -0,0 +1,21 @@ +export default { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/__tests__/**/*.test.ts"], + clearMocks: true, + extensionsToTreatAsEsm: [".ts"], + setupFilesAfterEnv: ["/src/tests/setup.ts"], + transform: { + "^.+\\.(t|j)sx?$": "ts-jest", + }, + transformIgnorePatterns: ["node_modules/(?!(.*@faker-js/faker))"], + moduleNameMapper: { + "^@app$": "/src/app.ts", + "^@server$": "/src/server.ts", + "^@modules/(.*)$": "/src/modules/$1", + "^@utils/(.*)$": "/src/utils/$1", + "^@config/(.*)$": "/src/config/$1", + "^@tests/(.*)$": "/src/tests/$1", + "^@middlewares/(.*)$": "/src/middlewares/$1", + }, +}; diff --git a/package.json b/package.json index ee9783f..cfbd93f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@faker-js/faker": "^10.1.0", "@types/morgan": "^1.9.10", "cors": "^2.8.5", "dotenv": "^16.6.1", diff --git a/src/modules/auth/__tests__/signup.v1.test.ts b/src/modules/auth/__tests__/signup.v1.test.ts new file mode 100644 index 0000000..ad38754 --- /dev/null +++ b/src/modules/auth/__tests__/signup.v1.test.ts @@ -0,0 +1,72 @@ +import request from "supertest"; +import app from "@app"; +import { userFixtures } from "@tests/fixtures/user"; +import { UserFactory } from "@tests/factories/user.factory"; +import { OrganizationFactory } from "@tests/factories/organization.factory"; + +describe("Auth Signup", () => { + it("should return 400 if organization name is missing", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...UserFactory.generate(), + }); + + expect(res.status).toBe(400); + }); + it("should return 400 if email is missing", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...userFixtures.noEmail, + ...OrganizationFactory.generate(), + }); + + expect(res.status).toBe(400); + }); + it("should return 400 if password is missing", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...userFixtures.noPassword, + ...OrganizationFactory.generate(), + }); + + expect(res.status).toBe(400); + }); + it("should return 400 if email is invalid", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...userFixtures.invalidEmail, + ...OrganizationFactory.generate(), + }); + + expect(res.status).toBe(400); + }); + it("should return 400 if organization size is missing", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...userFixtures.invalidEmail, + ...OrganizationFactory.generate({ size: undefined }), + }); + + expect(res.status).toBe(400); + }); + it("should return 201 when a user signs up successfully", async () => { + const orgData = OrganizationFactory.generate(); + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...UserFactory.generate(), + organizationName: orgData.name, + organizationSize: orgData.size, + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("data"); + expect(res.body.data).toHaveProperty("userId"); + expect(res.body.data).toHaveProperty("organizationId"); + }); +}); diff --git a/src/tests/auth/signup.test.ts b/src/tests/auth/signup.test.ts deleted file mode 100644 index 48f16ec..0000000 --- a/src/tests/auth/signup.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import request from "supertest"; -import app from "../../../src/app"; -import { orgFixtures } from "../fixtures/organization"; -import { userFixtures } from "../fixtures/user"; - -describe("User Signup", () => { - it("should return 201 when a user signs up successfully", async () => { - const res = await request(app) - .post("/api/auth/signup") - .send({ - ...userFixtures.validOwner, - organizationName: orgFixtures.validOrg.name, - }); - - expect(res.status).toBe(201); - expect(res.body).toHaveProperty("userId"); - }); - it("should return 400 if organizationName is missing", async () => { - const res = await request(app) - .post("/api/auth/signup") - .send({ - ...userFixtures.validOwner, - }); - - expect(res.status).toBe(400); - }); - it("should return 400 if email is missing", async () => { - const res = await request(app) - .post("/api/auth/signup") - .send({ - ...userFixtures.noEmail, - organizationName: orgFixtures.validOrg.name, - }); - - expect(res.status).toBe(400); - }); - it("should return 400 if password is missing", async () => { - const res = await request(app) - .post("/api/auth/signup") - .send({ - ...userFixtures.noPassword, - organizationName: orgFixtures.validOrg.name, - }); - - expect(res.status).toBe(400); - }); - it("should return 400 if email is invalid", async () => { - const res = await request(app) - .post("/api/auth/signup") - .send({ - ...userFixtures.invalidEmail, - organizationName: orgFixtures.validOrg.name, - }); - - expect(res.status).toBe(400); - }); -}); diff --git a/src/tests/factories/organization.factory.ts b/src/tests/factories/organization.factory.ts new file mode 100644 index 0000000..9e1e975 --- /dev/null +++ b/src/tests/factories/organization.factory.ts @@ -0,0 +1,9 @@ +import { faker } from "@faker-js/faker"; + +export const OrganizationFactory = { + generate: (overrides: Partial<{}> = {}) => ({ + name: faker.company.name(), + size: faker.number.int(), + ...overrides, + }), +}; diff --git a/src/tests/factories/user.factory.ts b/src/tests/factories/user.factory.ts new file mode 100644 index 0000000..4cf72e5 --- /dev/null +++ b/src/tests/factories/user.factory.ts @@ -0,0 +1,10 @@ +import { faker } from "@faker-js/faker"; + +export const UserFactory = { + generate: (overrides: Partial<{}> = {}) => ({ + name: faker.person.fullName(), + email: faker.internet.email(), + password: "SecurePass123!", + ...overrides, + }), +}; diff --git a/src/tests/fixtures/organization.ts b/src/tests/fixtures/organization.ts index 6234cf5..3f3e2f1 100644 --- a/src/tests/fixtures/organization.ts +++ b/src/tests/fixtures/organization.ts @@ -1,5 +1 @@ -export const orgFixtures = { - validOrg: { - name: "Startup & Co.", - }, -}; +export const orgFixtures = {}; diff --git a/src/tests/fixtures/user.ts b/src/tests/fixtures/user.ts index e9bbab7..dd8ff04 100644 --- a/src/tests/fixtures/user.ts +++ b/src/tests/fixtures/user.ts @@ -1,20 +1,17 @@ +import { faker } from "@faker-js/faker"; + export const userFixtures = { - validOwner: { - email: "owner@example.com", - password: "StrongPass123!", - name: "Test Owner", + noEmail: { + name: "John Doe", + password: "SecurePass123", }, noPassword: { - email: "nopass@example.com", - name: "No Password User", - }, - noEmail: { - password: "StrongPass123!", - name: "Invalid Email User", + name: "John Doe", + email: "john@example.com", }, invalidEmail: { + name: "Test User", email: "not-an-email", - password: "StrongPass123!", - name: "Invalid Email User", + password: "SomePass123", }, }; diff --git a/src/tests/setup.ts b/src/tests/setup.ts index eefea2e..2b67b62 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -1,12 +1,11 @@ import mongoose from "mongoose"; -import { MongoMemoryServer } from "mongodb-memory-server"; -import request from "supertest"; +import { MongoMemoryReplSet } from "mongodb-memory-server"; -let mongo: MongoMemoryServer; +let mongo: MongoMemoryReplSet; beforeAll(async () => { - mongo = await MongoMemoryServer.create(); + mongo = await MongoMemoryReplSet.create(); const uri = mongo.getUri(); await mongoose.connect(uri); }); diff --git a/tsconfig.json b/tsconfig.json index f0a08e5..6307bca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,18 @@ "sourceMap": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + + "baseUrl": "./", + "paths": { + "@app": ["src/app"], + "@server": ["src/server"], + "@tests/*": ["src/tests/*"], + "@utils/*": ["src/utils/*"], + "@modules/*": ["src/modules/*"], + "@config/*": ["src/config/*"], + "@middlewares/*": ["src/middlewares/*"] + } }, "include": ["src", "tests"] } From a27df96c2e4fc90d5de3db27abdd5d218c98933c Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 17 Oct 2025 09:00:30 +0100 Subject: [PATCH 03/48] feat: Create signup API for organization owner --- package-lock.json | 27 +++++++++++ package.json | 1 + src/app.ts | 5 ++- src/config/env.ts | 0 src/middlewares/errorHandler.ts | 24 ++++++++++ src/middlewares/validators.ts | 21 +++++++++ src/modules/auth/auth.controller.ts | 29 ++++++++++++ src/modules/auth/auth.service.ts | 45 +++++++++++++++++++ src/modules/auth/auth.types.ts | 17 +++++++ src/modules/auth/auth.validators.ts | 13 ++++++ src/modules/auth/routes/auth.v1.routes.ts | 14 ++++++ .../organization/organization.model.ts | 43 ++++++++++++++++++ .../organization/organization.types.ts | 19 ++++++++ src/modules/user/user.model.ts | 43 ++++++++++++++++++ src/modules/user/user.types.ts | 15 +++++++ src/routes/v1.route.ts | 7 +++ src/types.ts | 0 src/utils/AppError.ts | 44 ++++++++++++++++++ 18 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/config/env.ts create mode 100644 src/middlewares/errorHandler.ts create mode 100644 src/middlewares/validators.ts create mode 100644 src/modules/auth/auth.controller.ts create mode 100644 src/modules/auth/auth.service.ts create mode 100644 src/modules/auth/auth.types.ts create mode 100644 src/modules/auth/auth.validators.ts create mode 100644 src/modules/auth/routes/auth.v1.routes.ts create mode 100644 src/modules/organization/organization.model.ts create mode 100644 src/modules/organization/organization.types.ts create mode 100644 src/modules/user/user.model.ts create mode 100644 src/modules/user/user.types.ts create mode 100644 src/routes/v1.route.ts create mode 100644 src/types.ts create mode 100644 src/utils/AppError.ts diff --git a/package-lock.json b/package-lock.json index 973e8d1..e9522e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "1.0.0", "license": "UNLICENSED", "dependencies": { + "@faker-js/faker": "^10.1.0", "@types/morgan": "^1.9.10", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.19.2", @@ -751,6 +753,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", + "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2888,6 +2906,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/package.json b/package.json index cfbd93f..2003533 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@faker-js/faker": "^10.1.0", "@types/morgan": "^1.9.10", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.19.2", diff --git a/src/app.ts b/src/app.ts index 5adf2e8..b19b307 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,8 @@ import express, { Application } from "express"; import cors from "cors"; import morgan from "morgan"; - +import v1Router from "./routes/v1.route"; +import errorHandler from "./middlewares/errorHandler"; const app: Application = express(); @@ -9,8 +10,10 @@ app.use(cors()); app.use(express.json()); app.use(morgan("dev")); +app.use("/api/v1", v1Router); app.get("/api/health", (req, res) => { res.send({ status: "ok" }); }); +app.use(errorHandler); export default app; diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts new file mode 100644 index 0000000..d6780dc --- /dev/null +++ b/src/middlewares/errorHandler.ts @@ -0,0 +1,24 @@ +import { Request, Response } from "express"; +import AppError from "../utils/AppError"; + +const errorHandler = (err: any, _req: Request, res: Response) => { + let statusCode = err.statusCode || 500; + let message = err.message || "Internal Server Error"; + let details = err.details; + + if (err instanceof AppError) { + return res.status(statusCode).json({ + success: false, + error: message, + details, + }); + } + + return res.status(500).json({ + success: false, + error: "Something went wrong", + details: process.env.NODE_ENV === "development" ? err.stack : undefined, + }); +}; + +export default errorHandler; diff --git a/src/middlewares/validators.ts b/src/middlewares/validators.ts new file mode 100644 index 0000000..5fb80e4 --- /dev/null +++ b/src/middlewares/validators.ts @@ -0,0 +1,21 @@ +import { AnyZodObject } from "zod"; +import { Request, Response, NextFunction } from "express"; +import AppError from "@utils/AppError"; + +const validateResource = + (schema: AnyZodObject) => + (req: Request, _res: Response, next: NextFunction) => { + try { + schema.parse(req.body); + next(); + } catch (e: any) { + return next( + AppError.badRequest( + "Validation failed", + e.errors.map((err: any) => err.message), + ), + ); + } + }; + +export default validateResource; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..383f33e --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,29 @@ +import { Request, Response, NextFunction } from "express"; +import AuthService from "./auth.service"; +import { IErrorPayload, ISignupPayload, SignupInput } from "./auth.types"; +import AppError from "@utils/AppError"; + +export const signupOrganizationOwner = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const input: SignupInput = req.body; + + const result = await AuthService.signupOwner(input); + + if ((result as IErrorPayload).error) + return next( + AppError.badRequest((result as IErrorPayload).error || "Signup failed"), + ); + + return res.status(201).json({ + success: true, + message: "Owner signup successful", + data: (result as ISignupPayload).data, + }); + } catch (err) { + next(err); + } +}; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..9ebab87 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,45 @@ +import UserModel from "@modules/user/user.model"; +import { IErrorPayload, ISignupPayload, SignupInput } from "./auth.types"; +import OrganizationModel from "@modules/organization/organization.model"; +import mongoose from "mongoose"; + +const AuthService = { + signupOwner: async ( + input: SignupInput, + ): Promise => { + const { name, email, password, organizationName, organizationSize } = input; + const existingUser = await UserModel.exists({ email }); + if (existingUser) return { success: false, error: "User already exists" }; + + const session = await mongoose.startSession(); + session.startTransaction(); + try { + const createdUser = new UserModel({ + name, + email, + password, + role: "owner", + }); + const organization = new OrganizationModel({ + name: organizationName, + owner: createdUser._id, + size: organizationSize, + }); + createdUser.organization = organization._id; + await createdUser.save({ session }); + await organization.save({ session }); + await session.commitTransaction(); + session.endSession(); + return { + success: true, + data: { userId: createdUser._id, organizationId: organization._id }, + }; + } catch (err) { + await session.abortTransaction(); + session.endSession(); + throw err; + } + }, +}; + +export default AuthService; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts new file mode 100644 index 0000000..65e5428 --- /dev/null +++ b/src/modules/auth/auth.types.ts @@ -0,0 +1,17 @@ +import mongoose from "mongoose"; +import { signupSchema } from "./auth.validators"; +import { z } from "zod"; + +export type SignupInput = z.infer; + +export interface IErrorPayload { + success: boolean; + error: string; +} +export interface ISignupPayload { + success: boolean; + data: { + userId: mongoose.Types.ObjectId; + organizationId: mongoose.Types.ObjectId; + }; +} diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts new file mode 100644 index 0000000..06b4be7 --- /dev/null +++ b/src/modules/auth/auth.validators.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const signupSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters long"), + email: z.string().email("Invalid email format"), + password: z.string().min(6, "Password must be at least 6 characters"), + organizationName: z + .string() + .min(2, "Organization name must be at least 2 characters"), + organizationSize: z.number().int().min(1), +}); + +export type SignupInput = z.infer; diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts new file mode 100644 index 0000000..b21e94f --- /dev/null +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import validateResource from "@middlewares/validators"; +import { signupSchema } from "../auth.validators"; +import { signupOrganizationOwner } from "../auth.controller"; + +const authRouter = Router(); + +authRouter.post( + "/signup", + validateResource(signupSchema), + signupOrganizationOwner, +); + +export default authRouter; diff --git a/src/modules/organization/organization.model.ts b/src/modules/organization/organization.model.ts new file mode 100644 index 0000000..4ee8d11 --- /dev/null +++ b/src/modules/organization/organization.model.ts @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; +import { IOrganization } from "./organization.types"; + +const organizationSchema = new mongoose.Schema( + { + name: { + type: String, + required: [true, "An organization must have a name"], + unique: true, + trim: true, + minlength: [2, "Name must be at least 2 characters"], + maxlength: [50, "Name cannot exceed 50 characters"], + }, + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + domain: { type: String, lowercase: true, trim: true }, + description: { type: String, trim: true }, + status: { + type: String, + enum: ["active", "inactive"], + default: "active", + }, + size: { + type: Number, + required: [true, "Please specify the size of your organization"], + }, + settings: { + timezone: { type: String, default: "UTC" }, + workHours: { type: Number, default: 8 }, + }, + }, + { timestamps: true }, +); + +const OrganizationModel = mongoose.model( + "Organization", + organizationSchema, +); + +export default OrganizationModel; diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts new file mode 100644 index 0000000..a764461 --- /dev/null +++ b/src/modules/organization/organization.types.ts @@ -0,0 +1,19 @@ +import mongoose from "mongoose"; + +type Status = "active" | "inactive"; + +export interface IOrganization extends mongoose.Document { + _id: mongoose.Types.ObjectId; + name: string; + owner: mongoose.Schema.Types.ObjectId; + domain?: string; + description?: string; + createdAt: Date; + updatedAt: Date; + status: Status; + size: number; + settings: { + timezone: string; + workHours: number; + }; +} diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts new file mode 100644 index 0000000..b9e4f35 --- /dev/null +++ b/src/modules/user/user.model.ts @@ -0,0 +1,43 @@ +// src/modules/auth/auth.model.ts +import mongoose, { CallbackError, Schema } from "mongoose"; +import { IUser } from "./user.types"; +import bcrypt from "bcryptjs"; + +const userSchema = new Schema( + { + name: { type: String, required: true, trim: true }, + email: { type: String, required: true, unique: true, lowercase: true }, + password: { type: String, required: true }, + role: { + type: String, + enum: ["owner", "admin", "member", "viewer"], + default: "member", + }, + organization: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: function () { + return this.role === "member"; + }, + }, + }, + { timestamps: true }, +); + +userSchema.pre("save", async function (next) { + const thisObj = this as IUser; + + if (!this.isModified("password")) return next(); + + try { + const salt = await bcrypt.genSalt(10); + thisObj.password = await bcrypt.hash(thisObj.password, salt); + return next(); + } catch (e) { + return next(e as CallbackError); + } +}); + +const UserModel = mongoose.model("User", userSchema); + +export default UserModel; diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts new file mode 100644 index 0000000..3f161b2 --- /dev/null +++ b/src/modules/user/user.types.ts @@ -0,0 +1,15 @@ +import mongoose from "mongoose"; + +export type Role = "owner" | "admin" | "member" | "viewer"; + +export interface IUser extends mongoose.Document { + _id: mongoose.Types.ObjectId; + name: string; + email: string; + password: string; + role: Role; + permissions: string[]; + organization: mongoose.Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/routes/v1.route.ts b/src/routes/v1.route.ts new file mode 100644 index 0000000..4983ef6 --- /dev/null +++ b/src/routes/v1.route.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; +import authRouter from "@modules/auth/routes/auth.v1.routes"; + +const v1Router = Router(); +v1Router.use("/auth", authRouter); + +export default v1Router; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts new file mode 100644 index 0000000..01d2c33 --- /dev/null +++ b/src/utils/AppError.ts @@ -0,0 +1,44 @@ +export default class AppError extends Error { + public statusCode: number; + public status: string; + public isOperational: boolean; + public details?: any; + + constructor( + message: string, + statusCode: number = 500, + details?: any, + isOperational: boolean = true, + ) { + super(message); + + Object.setPrototypeOf(this, new.target.prototype); + + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith("4") ? "fail" : "error"; + this.isOperational = isOperational; + this.details = details; + + Error.captureStackTrace(this, this.constructor); + } + + static badRequest(message = "Bad Request", details?: any) { + return new AppError(message, 400, details); + } + + static unauthorized(message = "Unauthorized", details?: any) { + return new AppError(message, 401, details); + } + + static forbidden(message = "Forbidden", details?: any) { + return new AppError(message, 403, details); + } + + static notFound(message = "Not Found", details?: any) { + return new AppError(message, 404, details); + } + + static internal(message = "Internal Server Error", details?: any) { + return new AppError(message, 500, details, false); + } +} From e80a44f541167208d91896d01bd54a6de6e11561 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 20:48:20 +0100 Subject: [PATCH 04/48] refactor: Update user model to accept firstName & lastName --- jest.config.mjs | 1 + src/modules/auth/__tests__/signup.v1.test.ts | 20 ++++++++++++++++++++ src/modules/auth/auth.service.ts | 17 ++++++++++++++--- src/modules/auth/auth.types.ts | 4 ++-- src/modules/auth/auth.validators.ts | 3 ++- src/modules/user/user.model.ts | 3 ++- src/modules/user/user.types.ts | 3 ++- src/tests/factories/user.factory.ts | 3 ++- src/tests/fixtures/user.ts | 19 +++++++++++++++---- tsconfig.json | 3 ++- 10 files changed, 62 insertions(+), 14 deletions(-) diff --git a/jest.config.mjs b/jest.config.mjs index 378dbee..c60c8a3 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -17,5 +17,6 @@ export default { "^@config/(.*)$": "/src/config/$1", "^@tests/(.*)$": "/src/tests/$1", "^@middlewares/(.*)$": "/src/middlewares/$1", + "^@docs/(.*)$": "/src/docs/$1", }, }; diff --git a/src/modules/auth/__tests__/signup.v1.test.ts b/src/modules/auth/__tests__/signup.v1.test.ts index ad38754..e0fe4fb 100644 --- a/src/modules/auth/__tests__/signup.v1.test.ts +++ b/src/modules/auth/__tests__/signup.v1.test.ts @@ -34,6 +34,26 @@ describe("Auth Signup", () => { expect(res.status).toBe(400); }); + it("should return 400 if last name is missing", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...userFixtures.noFirstName, + ...OrganizationFactory.generate(), + }); + + expect(res.status).toBe(400); + }); + it("should return 400 if last name is missing", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...userFixtures.noLastName, + ...OrganizationFactory.generate(), + }); + + expect(res.status).toBe(400); + }); it("should return 400 if email is invalid", async () => { const res = await request(app) .post("/api/v1/auth/signup") diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 9ebab87..f373de3 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -7,7 +7,14 @@ const AuthService = { signupOwner: async ( input: SignupInput, ): Promise => { - const { name, email, password, organizationName, organizationSize } = input; + const { + firstName, + lastName, + email, + password, + organizationName, + organizationSize, + } = input; const existingUser = await UserModel.exists({ email }); if (existingUser) return { success: false, error: "User already exists" }; @@ -15,7 +22,8 @@ const AuthService = { session.startTransaction(); try { const createdUser = new UserModel({ - name, + firstName, + lastName, email, password, role: "owner", @@ -32,7 +40,10 @@ const AuthService = { session.endSession(); return { success: true, - data: { userId: createdUser._id, organizationId: organization._id }, + data: { + userId: createdUser._id.toString(), + organizationId: organization._id.toString(), + }, }; } catch (err) { await session.abortTransaction(); diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 65e5428..840dc8f 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -11,7 +11,7 @@ export interface IErrorPayload { export interface ISignupPayload { success: boolean; data: { - userId: mongoose.Types.ObjectId; - organizationId: mongoose.Types.ObjectId; + userId: string; + organizationId: string; }; } diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts index 06b4be7..ff5b0f4 100644 --- a/src/modules/auth/auth.validators.ts +++ b/src/modules/auth/auth.validators.ts @@ -1,7 +1,8 @@ import { z } from "zod"; export const signupSchema = z.object({ - name: z.string().min(2, "Name must be at least 2 characters long"), + firstName: z.string().min(2, "Name must be at least 2 characters long"), + lastName: z.string().min(2, "Name must be at least 2 characters long"), email: z.string().email("Invalid email format"), password: z.string().min(6, "Password must be at least 6 characters"), organizationName: z diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index b9e4f35..ed4f6e0 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -5,7 +5,8 @@ import bcrypt from "bcryptjs"; const userSchema = new Schema( { - name: { type: String, required: true, trim: true }, + firstName: { type: String, required: true, trim: true }, + lastName: { type: String, required: true, trim: true }, email: { type: String, required: true, unique: true, lowercase: true }, password: { type: String, required: true }, role: { diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index 3f161b2..0d60303 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -4,7 +4,8 @@ export type Role = "owner" | "admin" | "member" | "viewer"; export interface IUser extends mongoose.Document { _id: mongoose.Types.ObjectId; - name: string; + firstName: string; + lastName: string; email: string; password: string; role: Role; diff --git a/src/tests/factories/user.factory.ts b/src/tests/factories/user.factory.ts index 4cf72e5..f6b9819 100644 --- a/src/tests/factories/user.factory.ts +++ b/src/tests/factories/user.factory.ts @@ -2,7 +2,8 @@ import { faker } from "@faker-js/faker"; export const UserFactory = { generate: (overrides: Partial<{}> = {}) => ({ - name: faker.person.fullName(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), email: faker.internet.email(), password: "SecurePass123!", ...overrides, diff --git a/src/tests/fixtures/user.ts b/src/tests/fixtures/user.ts index dd8ff04..7fca24a 100644 --- a/src/tests/fixtures/user.ts +++ b/src/tests/fixtures/user.ts @@ -1,13 +1,24 @@ -import { faker } from "@faker-js/faker"; export const userFixtures = { noEmail: { - name: "John Doe", + firstName: "John", + lastName: "Doe", + password: "SecurePass123", + }, + noFirstName: { + lastName: "Doe", + email: "johndoe@mail.com", + password: "SecurePass123", + }, + noLastName: { + firstName: "John", + email: "johndoe@mail.com", password: "SecurePass123", }, noPassword: { - name: "John Doe", - email: "john@example.com", + firstName: "John", + lastName: "Doe", + email: "johndoe@mail.com", }, invalidEmail: { name: "Test User", diff --git a/tsconfig.json b/tsconfig.json index 6307bca..37605b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,8 @@ "@utils/*": ["src/utils/*"], "@modules/*": ["src/modules/*"], "@config/*": ["src/config/*"], - "@middlewares/*": ["src/middlewares/*"] + "@middlewares/*": ["src/middlewares/*"], + "@docs/*": ["src/docs/*"] } }, "include": ["src", "tests"] From 7952ce365cc87d8ccad9515f7f65705685939f13 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 20:50:14 +0100 Subject: [PATCH 05/48] refactor: Update organization to be uniquely identified by slug --- src/modules/auth/auth.docs.ts | 21 ++++++++++++++ .../organization/organization.model.ts | 29 +++++++++++++++---- .../organization/organization.types.ts | 1 + src/utils/index.ts | 6 ++++ 4 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 src/modules/auth/auth.docs.ts create mode 100644 src/utils/index.ts diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts new file mode 100644 index 0000000..2ad5c1e --- /dev/null +++ b/src/modules/auth/auth.docs.ts @@ -0,0 +1,21 @@ +import { Tspec } from "tspec"; +import { IErrorPayload, ISignupPayload } from "./auth.types"; +import { SignupInput } from "./auth.validators"; + +export type AuthApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/auth"; + tags: ["Authentication"]; + paths: { + "/signup": { + post: { + summary: "Signup an organization owner"; + path: { id: number }; + body: SignupInput; + responses: { + 201: ISignupPayload; + 400: IErrorPayload & { details?: string }; + }; + }; + }; + }; +}>; diff --git a/src/modules/organization/organization.model.ts b/src/modules/organization/organization.model.ts index 4ee8d11..cecf123 100644 --- a/src/modules/organization/organization.model.ts +++ b/src/modules/organization/organization.model.ts @@ -1,16 +1,17 @@ -import mongoose from "mongoose"; +import mongoose, { model } from "mongoose"; import { IOrganization } from "./organization.types"; +import { slugify } from "@utils/index"; const organizationSchema = new mongoose.Schema( { name: { type: String, - required: [true, "An organization must have a name"], - unique: true, + required: true, trim: true, - minlength: [2, "Name must be at least 2 characters"], - maxlength: [50, "Name cannot exceed 50 characters"], + minlength: 2, + maxlength: 50, }, + slug: { type: String, required: true, unique: true }, owner: { type: mongoose.Schema.Types.ObjectId, ref: "User", @@ -35,6 +36,24 @@ const organizationSchema = new mongoose.Schema( { timestamps: true }, ); + +organizationSchema.pre("validate", async function (next) { + if (!this.isModified("name")) return next(); + + const baseSlug = slugify(this.name); + let slug = baseSlug; + let counter = 1; + + while (await model("Organization").exists({ slug })) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + this.slug = slug; + next(); +}); + + const OrganizationModel = mongoose.model( "Organization", organizationSchema, diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index a764461..af7e6a1 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -5,6 +5,7 @@ type Status = "active" | "inactive"; export interface IOrganization extends mongoose.Document { _id: mongoose.Types.ObjectId; name: string; + slug: string; owner: mongoose.Schema.Types.ObjectId; domain?: string; description?: string; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..c7a7923 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,6 @@ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash + .replace(/(^-|-$)+/g, ""); // trim starting/ending dashes +} From 3dba265dac64b4ac0d853a44330a076f408596c1 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 20:51:21 +0100 Subject: [PATCH 06/48] feat: Add catch all not found route handler --- src/middlewares/notFound.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/middlewares/notFound.ts diff --git a/src/middlewares/notFound.ts b/src/middlewares/notFound.ts new file mode 100644 index 0000000..5da7b13 --- /dev/null +++ b/src/middlewares/notFound.ts @@ -0,0 +1,8 @@ +import { Request, Response } from "express"; + +export function notFound(req: Request, res: Response) { + res.status(404).json({ + success: false, + message: "Resource not found", + }); +} From d4e4747561c909d1201c753b95036b572f09cbeb Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 20:52:57 +0100 Subject: [PATCH 07/48] refactor: Update error handler to provide stack in development --- src/middlewares/errorHandler.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index d6780dc..1b6a0ee 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -16,8 +16,9 @@ const errorHandler = (err: any, _req: Request, res: Response) => { return res.status(500).json({ success: false, - error: "Something went wrong", - details: process.env.NODE_ENV === "development" ? err.stack : undefined, + error: message || "Something went wrong", + details, + stack: process.env.NODE_ENV === "development" ? err.stack : details, }); }; From 90b213cd0310568f22a744cef420a05342824d65 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 20:55:27 +0100 Subject: [PATCH 08/48] feat: Improve error handling by providing details --- src/middlewares/validators.ts | 9 ++++++++- src/utils/AppError.ts | 14 +++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/middlewares/validators.ts b/src/middlewares/validators.ts index 5fb80e4..027aa4c 100644 --- a/src/middlewares/validators.ts +++ b/src/middlewares/validators.ts @@ -12,7 +12,14 @@ const validateResource = return next( AppError.badRequest( "Validation failed", - e.errors.map((err: any) => err.message), + (e.errors as Array<{ path: string; message: string }>).reduce( + (acc: string, err: any, idx: number) => + acc + + `Error on path ${err.path}: ${err.message}${ + idx !== e.errors.length - 1 ? ", " : "" + }`, + "", + ), ), ); } diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts index 01d2c33..71c3270 100644 --- a/src/utils/AppError.ts +++ b/src/utils/AppError.ts @@ -2,12 +2,12 @@ export default class AppError extends Error { public statusCode: number; public status: string; public isOperational: boolean; - public details?: any; + public details: string | undefined; constructor( message: string, statusCode: number = 500, - details?: any, + details?: string, isOperational: boolean = true, ) { super(message); @@ -22,23 +22,23 @@ export default class AppError extends Error { Error.captureStackTrace(this, this.constructor); } - static badRequest(message = "Bad Request", details?: any) { + static badRequest(message = "Bad Request", details?: string) { return new AppError(message, 400, details); } - static unauthorized(message = "Unauthorized", details?: any) { + static unauthorized(message = "Unauthorized", details?: string) { return new AppError(message, 401, details); } - static forbidden(message = "Forbidden", details?: any) { + static forbidden(message = "Forbidden", details?: string) { return new AppError(message, 403, details); } - static notFound(message = "Not Found", details?: any) { + static notFound(message = "Not Found", details?: string) { return new AppError(message, 404, details); } - static internal(message = "Internal Server Error", details?: any) { + static internal(message = "Internal Server Error", details?: string) { return new AppError(message, 500, details, false); } } From 0f601e8a368596e91cd71bed9cbc832e569db3a7 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 20:55:53 +0100 Subject: [PATCH 09/48] Update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 6ed48a9..5b9add5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env node_modules +dist +openapi.json \ No newline at end of file From 844a65b6d2dbc241a83aac2051a92439acf8b2b2 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 20:57:31 +0100 Subject: [PATCH 10/48] docs: Update README, Create documentation process --- README.md | 191 +++++++++++++- package-lock.json | 528 ++++++++++++++++++++++++++++++++----- package.json | 9 +- src/app.ts | 32 ++- src/docs/tspecGenerator.ts | 21 ++ 5 files changed, 698 insertions(+), 83 deletions(-) create mode 100644 src/docs/tspecGenerator.ts diff --git a/README.md b/README.md index 2449d3c..571439d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Built with **Express + TypeScript + MongoDB**, following best practices. - CRUD for Timesheets - CRUD for Projects - Role-based Access Control (Admin, Employee, Manager) -- API Documentation (Swagger) +- API Documentation (TsSpec X SwaggerUI) --- @@ -19,13 +19,196 @@ Built with **Express + TypeScript + MongoDB**, following best practices. - **MongoDB (Mongoose)** - **Jest + Supertest** (Testing) - **ESLint + Prettier** (Linting/Formatting) -- **Swagger** (API Docs) +- **TsSpec X Swagger** (API Docs) --- ## 📦 Installation ```bash -git clone -cd backend +git clone https://github.com/Timesheets-By-Exploit/backend.git +cd backend npm install npm run dev +``` +--- + +## Git Workflow + +### 1. Create a New Branch + +--- + +Always start new branches from `develop`, not from other feature branches. + +--- + +```bash +# Create and switch to a new branch +git checkout -b feature/feature-name +``` + +--- + +### 2. Push a New Branch (first time) + +```bash +git push -u origin feature/feature-name +``` + +👉 `-u` sets the upstream, so future `git push`/`git pull` works without arguments. + +--- + +### 3. Rename a Branch + +```bash +# Rename current branch +git branch -m new-name + +# Rename a different branch +git branch -m old-name new-name +``` + +For remote: + +```bash +git push origin --delete old-name +git push -u origin new-name +``` + +--- + +### 4. Switch Branches + +```bash +git checkout develop +git checkout feature/feature-name +``` + +--- + +### 5. Keep Branch Updated (from develop/main) + +```bash +# While on your feature branch +git fetch origin +git rebase origin/develop +# or +git merge origin/develop +``` + +Prefer **rebase** for clean history, **merge** for simplicity. + +--- + +### 6. Stage, Commit, Push + +```bash +git add . +git commit -m "feat(auth): implement signup endpoint" +git push +``` + +--- + +### 7. Commit Message Convention + +Follow **Conventional Commits**: + +* `feat:` → new feature +* `fix:` → bug fix +* `test:` → testing work +* `docs:` → documentation updates +* `refactor:` → code refactoring +* `chore:` → maintenance (configs, deps) + +Examples: + +```bash +feat(auth): add signup API for org owners +test(auth): add validation tests for signup +docs(readme): add setup instructions +``` + +--- + +### 8. Pull Requests (PR) + +1. Push branch to remote. +2. Open PR into `develop` (not `main`). +3. Review → squash/merge. + +--- + +### 9. Hotfix (urgent fix on main) + +```bash +git checkout main +git checkout -b hotfix/fix-login +``` + +After fix: + +```bash +git commit -m "fix(auth): handle null password bug" +git push -u origin hotfix/fix-login +``` + +Open PR → merge into `main` **and** `develop`. + +--- + +### 10. Delete Branch + +```bash +# Local +git branch -d feature/auth-signup +# Remote +git push origin --delete feature/auth-signup +``` + + +### Folder Structure +``` +timesheets-backend/ +│ +├── .github/ # GitHub Actions CI/CD workflows +├── .husky/ # Git hooks (linting/tests pre-commit) +├── docs/ # Project documentation (markdowns, API specs) +│ └── tspecGenerator.ts +│ +├── src/ # Application source code +│ ├── config/ # App configurations (db, env, logger, etc.) +│ │ ├── db.ts +│ │ └── env.ts +│ │ +│ ├── modules/ # Each feature lives here (modular structure) +│ │ ├── module/ # Authentication module +│ │ ├── __tests__/ # Tests specific to module +│ │ ├── module.controller.ts +│ │ ├── module.docs.ts #Tspec Docs +│ │ ├── module.service.ts +│ │ ├── module.model.ts +│ │ ├── module.routes.ts +│ │ └── module.types.ts +│ │ +│ │ +│ ├── middlewares/ # Custom Express middlewares +│ │ ├── errorHandler.ts +│ │ ├── notFound.ts +│ │ └── validators.ts +│ │ +│ ├── utils/ # Utility/helper functions +│ │ ├── index.ts +│ │ └── AppError.ts +│ │ +│ ├── app.ts # Express app setup +│ └── server.ts # Server entry point +│ +├── .env # Local environment variables +├── .env.example # Sample env file for docs +├── jest.config.ts # Jest config +├── tsconfig.json # TypeScript config +├── package.json +└── README.md +``` diff --git a/package-lock.json b/package-lock.json index e9522e2..95f9e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,9 @@ "express": "^4.19.2", "mongoose": "^8.5.0", "morgan": "^1.10.1", + "swagger-ui-express": "^5.0.1", + "tsconfig-paths": "^4.2.0", + "tspec": "^0.1.116", "zod": "^3.23.8" }, "devDependencies": { @@ -25,12 +28,14 @@ "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/supertest": "^6.0.3", + "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", "supertest": "^7.1.4", "ts-jest": "^29.4.5", "ts-node-dev": "^2.0.0", + "tsc-alias": "^1.8.16", "typescript": "^5.9.3", "typescript-eslint": "^8.46.1" } @@ -562,11 +567,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@cloudflare/json-schema-walker": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@cloudflare/json-schema-walker/-/json-schema-walker-0.1.1.tgz", + "integrity": "sha512-P3n0hEgk1m6uKWgL4Yb1owzXVG4pM70G4kRnDQxZXiVvfCRtaqiHu+ZRiRPzmwGBiLTO1LWc2yR1M8oz0YkXww==", + "license": "BSD-3-Clause" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -1511,7 +1521,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1521,14 +1530,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -1642,6 +1649,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -1673,28 +1687,24 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -1757,7 +1767,7 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -1768,7 +1778,7 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1802,7 +1812,7 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -1815,7 +1825,7 @@ "version": "4.19.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1828,9 +1838,18 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.16", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", + "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1873,7 +1892,6 @@ "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/methods": { @@ -1887,7 +1905,7 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/morgan": { @@ -1912,21 +1930,21 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1936,7 +1954,7 @@ "version": "1.15.9", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -1948,7 +1966,7 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -2000,6 +2018,17 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -2583,7 +2612,6 @@ "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" @@ -2606,7 +2634,6 @@ "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": { "acorn": "^8.11.0" @@ -2662,7 +2689,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2672,7 +2698,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2702,7 +2727,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -2718,6 +2742,16 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -2860,7 +2894,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/bare-events": { @@ -2971,7 +3004,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2982,7 +3014,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3245,7 +3276,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3278,7 +3308,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3291,7 +3320,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3328,7 +3356,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/content-disposition": { @@ -3398,7 +3425,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -3519,12 +3545,24 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3598,7 +3636,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -3670,7 +3707,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3873,6 +3909,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -4098,7 +4140,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4226,7 +4267,6 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, "funding": [ { "type": "individual", @@ -4330,7 +4370,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -4371,7 +4410,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4437,12 +4475,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz", + "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -4485,6 +4535,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4606,6 +4677,44 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -4704,7 +4813,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -4766,7 +4874,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4776,7 +4883,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4796,7 +4902,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4809,12 +4914,23 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5688,6 +5804,16 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-openapi-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema-to-openapi-schema/-/json-schema-to-openapi-schema-0.4.0.tgz", + "integrity": "sha512-/DY8s4l28M5ZIJBhmcUFWbZChJV5v7RCA7RMVxubyD1l5KwIceUq6+EUnqQ2q3wh/2D3Zn8bNSeAu1i2X+sMHQ==", + "deprecated": "This package is no longer maintained. Use @openapi-contrib/json-schema-to-openapi-schema instead.", + "license": "MIT", + "dependencies": { + "@cloudflare/json-schema-walker": "^0.1.1" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5706,7 +5832,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -5825,7 +5950,6 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, "license": "ISC" }, "node_modules/makeerror": { @@ -5901,7 +6025,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -5958,7 +6081,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -5971,7 +6093,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6200,6 +6321,20 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mylas": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", + "integrity": "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -6335,7 +6470,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6357,6 +6491,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6465,6 +6605,12 @@ "node": ">= 0.8" } }, + "node_modules/path-equal": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/path-equal/-/path-equal-1.2.5.tgz", + "integrity": "sha512-i73IctDr3F2W+bsOWDyyVm/lqsXO47aY9nsFZUjTT/aljSbkxHxxCoyZ9UUrM8jK0JVod+An+rl48RCsvWM+9g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6479,7 +6625,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6532,6 +6677,16 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -6550,7 +6705,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6638,6 +6792,19 @@ "node": ">=8" } }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6730,6 +6897,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6799,12 +6976,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6859,6 +7041,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6928,6 +7120,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7233,7 +7434,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7264,7 +7464,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7291,7 +7490,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -7394,6 +7592,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.29.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.5.tgz", + "integrity": "sha512-2zFnjONgLXlz8gLToRKvXHKJdqXF6UGgCmv65i8T6i/UrjDNyV1fIQ7FauZA40SaivlGKEvW2tw9XDyDhfcXqQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -7458,7 +7680,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -7581,7 +7802,6 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -7656,6 +7876,38 @@ } } }, + "node_modules/tsc-alias": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", + "integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tsc-alias/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/tsconfig": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", @@ -7669,6 +7921,20 @@ "strip-json-comments": "^2.0.0" } }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tsconfig/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -7686,6 +7952,102 @@ "dev": true, "license": "0BSD" }, + "node_modules/tspec": { + "version": "0.1.116", + "resolved": "https://registry.npmjs.org/tspec/-/tspec-0.1.116.tgz", + "integrity": "sha512-wPln1/0JR97RWALE7WcMZxZvf5lKWkh+WoWKXvbzwq4l4KZuPEpQXUYl5f/y8sR7sHReWmBVmdtHfTF496S6hA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "express": "^4.18.2", + "glob": "^8.1.0", + "http-proxy-middleware": "^2.0.6", + "json-schema-to-openapi-schema": "^0.4.0", + "openapi-types": "^12.0.2", + "swagger-ui-express": "^4.6.2", + "typescript": "~5.1.0", + "typescript-json-schema": "^0.62.0", + "yargs": "^17.7.1" + }, + "bin": { + "tspec": "dist/cli.js" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "express": "^4.17.1" + } + }, + "node_modules/tspec/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==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/tspec/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tspec/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tspec/node_modules/swagger-ui-express": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.6.3.tgz", + "integrity": "sha512-CDje4PndhTD2HkgyKH3pab+LKspDeB/NhPN2OF1j+piYIamQqBYwAXWESOT1Yju2xFg51bRW9sUng2WxDjzArw==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=4.11.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tspec/node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7739,7 +8101,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7773,6 +8134,44 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typescript-json-schema": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.62.0.tgz", + "integrity": "sha512-qRO6pCgyjKJ230QYdOxDRpdQrBeeino4v5p2rYmSD72Jf4rD3O+cJcROv46sQukm46CLWoeusqvBgKpynEv25g==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/json-schema": "^7.0.9", + "@types/node": "^16.9.2", + "glob": "^7.1.7", + "path-equal": "^1.2.5", + "safe-stable-stringify": "^2.2.0", + "ts-node": "^10.9.1", + "typescript": "~5.1.0", + "yargs": "^17.1.1" + }, + "bin": { + "typescript-json-schema": "bin/typescript-json-schema" + } + }, + "node_modules/typescript-json-schema/node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "license": "MIT" + }, + "node_modules/typescript-json-schema/node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -7891,7 +8290,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -7998,7 +8396,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -8035,7 +8432,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -8079,7 +8475,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -8096,7 +8491,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -8115,7 +8509,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -8139,7 +8532,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index 2003533..e3bb23a 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "dist/server.js", "type": "commonjs", "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/server.ts", - "build": "tsc", + "dev": "ts-node-dev --respawn --transpile-only -r tsconfig-paths/register src/server.ts", + "build": "tsc && tsc-alias", "start": "node dist/server.js", "lint": "eslint . --ext .ts --fix", "lint:check": "eslint . --ext .ts", @@ -22,6 +22,9 @@ "express": "^4.19.2", "mongoose": "^8.5.0", "morgan": "^1.10.1", + "swagger-ui-express": "^5.0.1", + "tsconfig-paths": "^4.2.0", + "tspec": "^0.1.116", "zod": "^3.23.8" }, "devDependencies": { @@ -30,12 +33,14 @@ "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/supertest": "^6.0.3", + "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", "supertest": "^7.1.4", "ts-jest": "^29.4.5", "ts-node-dev": "^2.0.0", + "tsc-alias": "^1.8.16", "typescript": "^5.9.3", "typescript-eslint": "^8.46.1" } diff --git a/src/app.ts b/src/app.ts index b19b307..ef26c19 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,19 +1,33 @@ -import express, { Application } from "express"; +import express, { Application, NextFunction, Request, Response } from "express"; import cors from "cors"; import morgan from "morgan"; import v1Router from "./routes/v1.route"; import errorHandler from "./middlewares/errorHandler"; +import swaggerUi from "swagger-ui-express"; +import { notFound } from "@middlewares/notFound"; +import getTSpec from "@docs/tspecGenerator"; const app: Application = express(); -app.use(cors()); -app.use(express.json()); -app.use(morgan("dev")); +(async () => { + app.use(cors()); + app.use(express.json()); + app.use(morgan("dev")); -app.use("/api/v1", v1Router); -app.get("/api/health", (req, res) => { - res.send({ status: "ok" }); -}); -app.use(errorHandler); + app.use("/api/v1", v1Router); + + app.get("/api/health", (req, res) => { + res.send({ status: "ok" }); + }); + app.use( + "/api/v1/docs", + swaggerUi.serve, + swaggerUi.setup(await getTSpec()), + ); + app.use("*", notFound); + app.use((err: Error, req: Request, res: Response, _next: NextFunction) => + errorHandler(err, req, res), + ); +})(); export default app; diff --git a/src/docs/tspecGenerator.ts b/src/docs/tspecGenerator.ts new file mode 100644 index 0000000..526d9ea --- /dev/null +++ b/src/docs/tspecGenerator.ts @@ -0,0 +1,21 @@ + + +import { generateTspec, Tspec } from "tspec"; + +const options: Tspec.GenerateParams = { + specPathGlobs: ["src/**/*.ts"], + tsconfigPath: "./tsconfig.json", + outputPath: "openapi.json", + specVersion: 3, + openapi: { + title: "Timesheets By Exploit", + version: "1.0.0", + description: "This is the official documentation of the Timesheets By Exploit API", + }, + debug: false, + ignoreErrors: true, +}; + +export default async function getTSpec(){ + return await generateTspec(options); +} \ No newline at end of file From d8d3f0f7e2b07d8307644c8e38a70c5a77855623 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 21:16:59 +0100 Subject: [PATCH 11/48] docs: Update signup documentation --- src/modules/auth/auth.docs.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index 2ad5c1e..55e7e2c 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -9,7 +9,6 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ "/signup": { post: { summary: "Signup an organization owner"; - path: { id: number }; body: SignupInput; responses: { 201: ISignupPayload; From 9bb13d18a797cb5baef07e58842d93e18def1e29 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 21:29:00 +0100 Subject: [PATCH 12/48] chore: setup env validation with zod --- .env.example | 4 ++++ src/config/db.ts | 3 ++- src/config/env.ts | 19 +++++++++++++++++++ src/middlewares/errorHandler.ts | 3 ++- src/server.ts | 3 +-- 5 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..391215d --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +PORT=$PORT +MONGODB_URI=$MONGODB_URI +JWT_SECRET=$JWT_SECRET +NODE_ENV=$NODE_ENV diff --git a/src/config/db.ts b/src/config/db.ts index 2bcc1db..592088e 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -1,8 +1,9 @@ import mongoose from "mongoose"; +import { MONGODB_URI } from "./env"; const connectDB = async () => { try { - const conn = await mongoose.connect(process.env.MONGODB_URI as string); + const conn = await mongoose.connect(MONGODB_URI as string); console.log(`✅ MongoDB Connected: ${conn.connection.host}`); } catch (error) { console.error("🚨 MongoDB connection failed:", error); diff --git a/src/config/env.ts b/src/config/env.ts index e69de29..7539d7a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +const envSchema = z.object({ + PORT: z.string().default("4000"), + MONGODB_URI: z.string().url(), + JWT_SECRET: z.string(), + NODE_ENV: z + .enum(["development", "production", "staging"]) + .default("development"), +}); + +const env = envSchema.safeParse(process.env); + +if (!env.success) { + console.error("❌ Invalid environment variables:", env.error.format()); + process.exit(1); // Exit if validation fails +} + +export const { PORT, MONGODB_URI, JWT_SECRET, NODE_ENV } = env.data; diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index 1b6a0ee..a5f5d9b 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,5 +1,6 @@ import { Request, Response } from "express"; import AppError from "../utils/AppError"; +import { NODE_ENV } from "@config/env"; const errorHandler = (err: any, _req: Request, res: Response) => { let statusCode = err.statusCode || 500; @@ -18,7 +19,7 @@ const errorHandler = (err: any, _req: Request, res: Response) => { success: false, error: message || "Something went wrong", details, - stack: process.env.NODE_ENV === "development" ? err.stack : details, + stack: NODE_ENV === "development" ? err.stack : details, }); }; diff --git a/src/server.ts b/src/server.ts index 765364b..ef83fb6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,8 +4,7 @@ import dotenv from "dotenv"; dotenv.config(); import app from "./app"; import connectDB from "./config/db"; - -const PORT = process.env.PORT || 4000; +import { PORT } from "@config/env"; connectDB().then(() => { app.listen(PORT, () => { From 9bf46df26b2de50664c33ca6323f8e3cf5d1356b Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 23:19:28 +0100 Subject: [PATCH 13/48] chore: setup env validation with zod --- src/config/env.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index 7539d7a..584d8a3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -5,14 +5,27 @@ const envSchema = z.object({ MONGODB_URI: z.string().url(), JWT_SECRET: z.string(), NODE_ENV: z - .enum(["development", "production", "staging"]) + .enum(["development", "production", "staging", "test"]) .default("development"), }); -const env = envSchema.safeParse(process.env); +const isTest = process.env.NODE_ENV === "test"; + +const env = isTest + ? { + success: true, + data: { + PORT: "4000", + MONGODB_URI: "mongodb://localhost:27017/test", + JWT_SECRET: "testsecret", + NODE_ENV: "test", + }, + error: envSchema.safeParse(process.env).error, + } + : envSchema.safeParse(process.env); if (!env.success) { - console.error("❌ Invalid environment variables:", env.error.format()); + console.error("❌ Invalid environment variables:", env.error?.format()); process.exit(1); // Exit if validation fails } From 0b4e9f9d915a4cd817a5def00637dc513d90119f Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sun, 19 Oct 2025 23:19:43 +0100 Subject: [PATCH 14/48] chore: setup env validation with zod --- src/config/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/env.ts b/src/config/env.ts index 584d8a3..07e13c8 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -26,7 +26,7 @@ const env = isTest if (!env.success) { console.error("❌ Invalid environment variables:", env.error?.format()); - process.exit(1); // Exit if validation fails + process.exit(1); } export const { PORT, MONGODB_URI, JWT_SECRET, NODE_ENV } = env.data; From fb75d493ccb93b038a2f9762632f83f3e807638b Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 12:31:43 +0100 Subject: [PATCH 15/48] chore: restructure test directory --- src/modules/auth/__tests__/{ => integration}/signup.v1.test.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/modules/auth/__tests__/{ => integration}/signup.v1.test.ts (100%) diff --git a/src/modules/auth/__tests__/signup.v1.test.ts b/src/modules/auth/__tests__/integration/signup.v1.test.ts similarity index 100% rename from src/modules/auth/__tests__/signup.v1.test.ts rename to src/modules/auth/__tests__/integration/signup.v1.test.ts From 44c3e9c5ff4cdb4408d0228d99fa6270a1e3ba36 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 12:34:05 +0100 Subject: [PATCH 16/48] test: Write unit tests for email verification --- src/config/constants.ts | 12 +++++++ .../__tests__/unit/verifyEmail.unit.test.ts | 34 +++++++++++++++++++ src/types/index.ts | 2 ++ src/utils/index.ts | 26 ++++++++++++-- 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/config/constants.ts create mode 100644 src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts create mode 100644 src/types/index.ts diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 0000000..aea854b --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1,12 @@ +export const TIME_UNITS = { + minutes: 60 * 1000, + mins: 60 * 1000, + min: 60 * 1000, + hours: 60 * 60 * 1000, + hrs: 60 * 60 * 1000, + hr: 60 * 60 * 1000, + seconds: 1000, + secs: 1000, + sec: 1000, +}; + diff --git a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts new file mode 100644 index 0000000..07700aa --- /dev/null +++ b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts @@ -0,0 +1,34 @@ +import UserModel from "@modules/user/user.model"; +import { UserFactory } from "@tests/factories/user.factory"; +import { convertTimeToMilliseconds } from "@utils/index"; + +describe("Email Verification Code Logic", () => { + it("Successfully generates a hashed email verification code", async () => { + const user = new UserModel(UserFactory.generate()); + const code = user.generateEmailVerificationCode(); + expect(code).toHaveLength(6); + expect(code).not.toBe(user.emailVerificationCode); + expect(user.emailVerificationCodeExpiry).toBeDefined(); + }); + + it("Fails verification if code is wrong", async () => { + const user = new UserModel(UserFactory.generate()); + const code = user.generateEmailVerificationCode(); + const isCorrectCode = user.verifyEmailVerificationCode( + code.split("").reverse().join(""), + ); + expect(user.isEmailVerified).toBe(false); + expect(isCorrectCode).toBe(false); + }); + + it("Fails verification if code is expired", async () => { + const user = new UserModel(UserFactory.generate()); + const code = user.generateEmailVerificationCode(); + user.emailVerificationCodeExpiry = new Date( + Date.now() - convertTimeToMilliseconds(60, "minutes"), + ); + const isCorrectCode = user.verifyEmailVerificationCode(code); + expect(user.isEmailVerified).toBe(false); + expect(isCorrectCode).toBe(false); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..9345945 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,2 @@ + +export type UNIT_OF_TIME = "minutes" | "min" | "hours" | "hr" | "seconds" | "sec"; \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index c7a7923..8cd4b8e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,28 @@ +import { TIME_UNITS } from "@config/constants"; +import { UNIT_OF_TIME } from "src/types"; + export function slugify(text: string): string { return text .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash - .replace(/(^-|-$)+/g, ""); // trim starting/ending dashes + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)+/g, ""); +} + +export function formatZodErrors( + errors: Array<{ path: string; message: string }>, +) { + return errors.reduce( + (acc: string, err: any, idx: number) => + acc + + `Error on path ${err.path}: ${err.message}${ + idx !== errors.length - 1 ? ", " : "" + }`, + "", + ); +} + +export function convertTimeToMilliseconds(value: number, unit: UNIT_OF_TIME) { + if (TIME_UNITS[unit as keyof typeof TIME_UNITS]) + return value * TIME_UNITS[unit as keyof typeof TIME_UNITS]; + return 0; } From 52da2cbb2bd27f945bb48bbb51fcad141db9947b Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 12:35:05 +0100 Subject: [PATCH 17/48] chore: install zeptomail library --- package-lock.json | 52 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 53 insertions(+) diff --git a/package-lock.json b/package-lock.json index 95f9e47..eb4fe4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", + "zeptomail": "^6.2.1", "zod": "^3.23.8" }, "devDependencies": { @@ -6387,6 +6388,48 @@ "node": ">=12.22.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -8550,6 +8593,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zeptomail": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/zeptomail/-/zeptomail-6.2.1.tgz", + "integrity": "sha512-5QbN0RACqXrvE4yS0ETvZLx5zC/DRDN2QZlse0HQKMrB4R2G0ciY5rgFZDFUAqssYxCFOyOhKo3QUphRYfrVUg==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index e3bb23a..6713e6d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", + "zeptomail": "^6.2.1", "zod": "^3.23.8" }, "devDependencies": { From 3dddc93f3e6102e303cab325cd6538789c0d2546 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 12:35:46 +0100 Subject: [PATCH 18/48] chore: add script for unit testing --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6713e6d..0e310a5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "lint": "eslint . --ext .ts --fix", "lint:check": "eslint . --ext .ts", "test": "jest --runInBand --detectOpenHandles", + "test:unit": "jest --runInBand --detectOpenHandles --testPathPatterns=__tests__/unit", "test:watch": "jest --watch" }, "dependencies": { From f100f1b9129fb1c778dfc322f088acd72c1cacb6 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 12:36:59 +0100 Subject: [PATCH 19/48] chore: add new paths to config --- jest.config.mjs | 1 + tsconfig.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/jest.config.mjs b/jest.config.mjs index c60c8a3..8afa0a0 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -18,5 +18,6 @@ export default { "^@tests/(.*)$": "/src/tests/$1", "^@middlewares/(.*)$": "/src/middlewares/$1", "^@docs/(.*)$": "/src/docs/$1", + "^@services/(.*)$": "/src/services/$1", }, }; diff --git a/tsconfig.json b/tsconfig.json index 37605b2..a016fb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "@modules/*": ["src/modules/*"], "@config/*": ["src/config/*"], "@middlewares/*": ["src/middlewares/*"], - "@docs/*": ["src/docs/*"] + "@docs/*": ["src/docs/*"], + "@services/*": ["src/services/*"] } }, "include": ["src", "tests"] From 43f2c17dbc189fdbbda2c923aeb918253cf1f33f Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 12:41:02 +0100 Subject: [PATCH 20/48] chore: add new env variables --- src/config/env.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/config/env.ts b/src/config/env.ts index 07e13c8..66828f4 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -7,6 +7,11 @@ const envSchema = z.object({ NODE_ENV: z .enum(["development", "production", "staging", "test"]) .default("development"), + ZEPTO_MAIL_TOKEN: z.string(), + ZEPTO_MAIL_URL: z.string(), + FROM_EMAIL: z.string().email(), + SUPPORT_EMAIL: z.string().email(), + FROM_NAME: z.string(), }); const isTest = process.env.NODE_ENV === "test"; @@ -19,6 +24,11 @@ const env = isTest MONGODB_URI: "mongodb://localhost:27017/test", JWT_SECRET: "testsecret", NODE_ENV: "test", + ZEPTO_MAIL_TOKEN: "", + ZEPTO_MAIL_URL: "", + SUPPORT_EMAIL: "", + FROM_EMAIL: "", + FROM_NAME: "", }, error: envSchema.safeParse(process.env).error, } @@ -29,4 +39,14 @@ if (!env.success) { process.exit(1); } -export const { PORT, MONGODB_URI, JWT_SECRET, NODE_ENV } = env.data; +export const { + PORT, + MONGODB_URI, + JWT_SECRET, + NODE_ENV, + ZEPTO_MAIL_TOKEN, + ZEPTO_MAIL_URL, + FROM_EMAIL, + FROM_NAME, + SUPPORT_EMAIL, +} = env.data; From 9f6f82c32e5614ca61a2768b862e73372f890217 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 12:51:20 +0100 Subject: [PATCH 21/48] chore: create type declaration for zeptomail --- src/types.ts | 0 src/types/zeptomail.d.ts | 54 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) delete mode 100644 src/types.ts create mode 100644 src/types/zeptomail.d.ts diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/types/zeptomail.d.ts b/src/types/zeptomail.d.ts new file mode 100644 index 0000000..27c0d17 --- /dev/null +++ b/src/types/zeptomail.d.ts @@ -0,0 +1,54 @@ +declare module "zeptomail" { + export type SendMailWithTemplatePayload = { + from?: { + address: string; + name: string; + }; + mail_template_key: string; + template_alias: string; + to: { + email_address: { + address: string; + name: string; + }; + }[]; + subject?: string; + content?: Array<{ type: string; value: string }>; + cc?: [ + { + email_address: { + address: string; + name: string; + }; + }, + ]; + bcc?: [ + { + email_address: { + address: string; + name: string; + }; + }, + ]; + merge_info?: { + [x: string]: string | number | boolean; + }; + reply_to?: [ + { + address: string; + name: string; + }, + ]; + client_reference?: string; + mime_headers?: { + [x: string]: string; + }; + }; + + export class SendMailClient { + constructor(options?: { url: string; token?: string } | string); + sendMailWithTemplate(payload: SendMailPayload): Promise; + } + + export { SendMailClient }; +} From cabab91eb5064f496cdc7f234d5ceb3e8b1da998 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 12:52:33 +0100 Subject: [PATCH 22/48] chore: increase timeout for test hooks --- src/tests/setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/setup.ts b/src/tests/setup.ts index 2b67b62..77ef08e 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -8,7 +8,7 @@ beforeAll(async () => { mongo = await MongoMemoryReplSet.create(); const uri = mongo.getUri(); await mongoose.connect(uri); -}); +}, 120000); beforeEach(async () => { const collections = await mongoose.connection.db?.collections(); @@ -20,4 +20,4 @@ beforeEach(async () => { afterAll(async () => { await mongoose.connection.close(); if (mongo) await mongo.stop(); -}); +}, 120000); From 272fc138d1ab84bf3ee6b2aa28254c5850d16102 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 13:03:53 +0100 Subject: [PATCH 23/48] feat: add feature to send emailverification code --- src/modules/auth/auth.controller.ts | 5 +-- src/modules/auth/auth.docs.ts | 5 +-- src/modules/auth/auth.service.ts | 49 +++++++++++++++++++++++++++-- src/modules/auth/auth.types.ts | 19 +++++------ src/modules/user/user.model.ts | 24 ++++++++++++-- src/modules/user/user.types.ts | 5 +++ src/services/email.service.ts | 34 ++++++++++++++++++++ src/types/index.ts | 11 ++++++- src/utils/encryptors.ts | 11 +++++++ src/utils/generators.ts | 18 +++++++++++ 10 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 src/services/email.service.ts create mode 100644 src/utils/encryptors.ts create mode 100644 src/utils/generators.ts diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 383f33e..4efc8f8 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,7 +1,8 @@ import { Request, Response, NextFunction } from "express"; import AuthService from "./auth.service"; -import { IErrorPayload, ISignupPayload, SignupInput } from "./auth.types"; +import { SignupInput, SignupOutput } from "./auth.types"; import AppError from "@utils/AppError"; +import { IErrorPayload, ISuccessPayload } from "src/types"; export const signupOrganizationOwner = async ( req: Request, @@ -21,7 +22,7 @@ export const signupOrganizationOwner = async ( return res.status(201).json({ success: true, message: "Owner signup successful", - data: (result as ISignupPayload).data, + data: (result as ISuccessPayload).data, }); } catch (err) { next(err); diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index 55e7e2c..9789bcb 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -1,6 +1,7 @@ import { Tspec } from "tspec"; -import { IErrorPayload, ISignupPayload } from "./auth.types"; import { SignupInput } from "./auth.validators"; +import { ISuccessPayload, IErrorPayload } from "src/types"; +import { SignupOutput } from "./auth.types"; export type AuthApiSpec = Tspec.DefineApiSpec<{ basePath: "/api/v1/auth"; @@ -11,7 +12,7 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ summary: "Signup an organization owner"; body: SignupInput; responses: { - 201: ISignupPayload; + 201: ISuccessPayload; 400: IErrorPayload & { details?: string }; }; }; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index f373de3..43b67de 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,12 +1,19 @@ import UserModel from "@modules/user/user.model"; -import { IErrorPayload, ISignupPayload, SignupInput } from "./auth.types"; +import { + EmailVerificationOutput, + SignupInput, + SignupOutput, +} from "./auth.types"; import OrganizationModel from "@modules/organization/organization.model"; import mongoose from "mongoose"; +import { IUser } from "@modules/user/user.types"; +import { sendEmailWithTemplate } from "@services/email.service"; +import { ISuccessPayload, IErrorPayload } from "src/types"; const AuthService = { signupOwner: async ( input: SignupInput, - ): Promise => { + ): Promise | IErrorPayload> => { const { firstName, lastName, @@ -38,11 +45,16 @@ const AuthService = { await organization.save({ session }); await session.commitTransaction(); session.endSession(); + const res = await AuthService.sendVerificationEmail(createdUser); + return { success: true, data: { userId: createdUser._id.toString(), organizationId: organization._id.toString(), + emailSent: res.success + ? (res as ISuccessPayload).data.emailSent + : false, }, }; } catch (err) { @@ -51,6 +63,39 @@ const AuthService = { throw err; } }, + sendVerificationEmail: async ( + user: IUser, + ): Promise | IErrorPayload> => { + try { + const code = user.generateEmailVerificationCode(); + await user.save(); + let emailSentResponse = await sendEmailWithTemplate({ + to: [ + { + email_address: { + address: user.email, + name: `${user.firstName} ${user.lastName}`, + }, + }, + ], + merge_info: { + emailVerificationCode: code, + emailVerificationExpiry: "30 minutes", + name: user.firstName, + }, + subject: "Verify your email", + mail_template_key: + "2d6f.45d8a1809f293f51.k1.46541e40-afd8-11f0-a465-fae9afc80e45.19a0fb93624", + template_alias: "email-verification", + }); + return { + success: emailSentResponse.success, + data: { emailSent: emailSentResponse.emailSent || false }, + }; + } catch (err: any) { + return { success: false, error: err.message }; + } + }, }; export default AuthService; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 840dc8f..9bb17ee 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -1,17 +1,12 @@ -import mongoose from "mongoose"; import { signupSchema } from "./auth.validators"; import { z } from "zod"; export type SignupInput = z.infer; -export interface IErrorPayload { - success: boolean; - error: string; -} -export interface ISignupPayload { - success: boolean; - data: { - userId: string; - organizationId: string; - }; -} +export type SignupOutput = { + organizationId: string; + userId: string; + emailSent: boolean; +}; + +export type EmailVerificationOutput = { emailSent: boolean }; diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index ed4f6e0..5572dae 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -1,7 +1,8 @@ // src/modules/auth/auth.model.ts import mongoose, { CallbackError, Schema } from "mongoose"; import { IUser } from "./user.types"; -import bcrypt from "bcryptjs"; +import { hashWithBcrypt, hashWithCrypto } from "@utils/encryptors"; +import { convertTimeToMilliseconds } from "@utils/index"; const userSchema = new Schema( { @@ -21,6 +22,9 @@ const userSchema = new Schema( return this.role === "member"; }, }, + isEmailVerified: { type: Boolean, default: false }, + emailVerificationCode: { type: String, default: null }, + emailVerificationCodeExpiry: { type: Date, default: null }, }, { timestamps: true }, ); @@ -31,14 +35,28 @@ userSchema.pre("save", async function (next) { if (!this.isModified("password")) return next(); try { - const salt = await bcrypt.genSalt(10); - thisObj.password = await bcrypt.hash(thisObj.password, salt); + thisObj.password = await hashWithBcrypt(thisObj.password); return next(); } catch (e) { return next(e as CallbackError); } }); +userSchema.pre("save", async function (next) { + const thisObj = this as IUser; + if (!this.isModified("emailVerificationCode")) return next(); + thisObj.emailVerificationCodeExpiry = new Date( + Date.now() + convertTimeToMilliseconds(30, "minutes"), + ); +}); + +userSchema.methods.generateEmailVerificationCode = function (): string { + const code = Math.floor(100000 + Math.random() * 900000).toString(); + this.emailVerificationCode = hashWithCrypto(code); + return code; +}; + + const UserModel = mongoose.model("User", userSchema); export default UserModel; diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index 0d60303..f244ff0 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -13,4 +13,9 @@ export interface IUser extends mongoose.Document { organization: mongoose.Types.ObjectId; createdAt: Date; updatedAt: Date; + isEmailVerified: boolean; + emailVerificationCode: string; + emailVerificationCodeExpiry: Date; + generateEmailVerificationCode: () => string; + verifyEmailVerificationCode: (code: string) => boolean; } diff --git a/src/services/email.service.ts b/src/services/email.service.ts new file mode 100644 index 0000000..e853afa --- /dev/null +++ b/src/services/email.service.ts @@ -0,0 +1,34 @@ +import { SendMailClient, SendMailWithTemplatePayload } from "zeptomail"; +import { + FROM_EMAIL, + ZEPTO_MAIL_URL, + ZEPTO_MAIL_TOKEN, + FROM_NAME, + SUPPORT_EMAIL, +} from "@config/env"; + +let client = new SendMailClient({ + url: ZEPTO_MAIL_URL, + token: ZEPTO_MAIL_TOKEN, +}); + +export async function sendEmailWithTemplate( + options: SendMailWithTemplatePayload, +) { + try { + const res = await client.sendMailWithTemplate({ + ...options, + from: options.from || { + name: FROM_NAME, + address: FROM_EMAIL, + }, + merge_info: { + ...(options.merge_info || {}), + supportEmail: SUPPORT_EMAIL, + }, + }); + return { success: res.message === "OK", emailSent: res.message === "OK" }; + } catch (err: any) { + return { success: false, error: err.error.message }; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 9345945..248c3e2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,11 @@ -export type UNIT_OF_TIME = "minutes" | "min" | "hours" | "hr" | "seconds" | "sec"; \ No newline at end of file +export type UNIT_OF_TIME = "minutes" | "min" | "hours" | "hr" | "seconds" | "sec"; + +export interface IErrorPayload { + success: boolean; + error: string; +} +export interface ISuccessPayload { + success: boolean; + data: T; +} \ No newline at end of file diff --git a/src/utils/encryptors.ts b/src/utils/encryptors.ts new file mode 100644 index 0000000..6f603a4 --- /dev/null +++ b/src/utils/encryptors.ts @@ -0,0 +1,11 @@ +import bcrypt from "bcryptjs"; +import crypto from "crypto"; + +export function hashWithCrypto(code: crypto.BinaryLike) { + return crypto.createHash("sha256").update(code).digest("hex"); +} + +export async function hashWithBcrypt(str: string, salt?: string) { + if (!salt) salt = await bcrypt.genSalt(10); + return await bcrypt.hash(str, salt); +} diff --git a/src/utils/generators.ts b/src/utils/generators.ts new file mode 100644 index 0000000..d155f58 --- /dev/null +++ b/src/utils/generators.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export function generateCode(numberOfDigits: number) { + const isNumberOfDigitsGeneratable = z + .number() + .int() + .gt(2) + .lt(16) + .safeParse(numberOfDigits); + + if (isNumberOfDigitsGeneratable.error) + throw new Error(isNumberOfDigitsGeneratable.error.message); + + return Math.floor( + Math.pow(10, numberOfDigits - 1) + + Math.random() * 9 * Math.pow(10, numberOfDigits - 1), + ).toString(); +} From 9c77eef1c82f72e75dd4c0fdc6a81c19118d3040 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 25 Oct 2025 14:14:10 +0100 Subject: [PATCH 24/48] feat: add verify-email endpoint --- src/modules/auth/auth.controller.ts | 31 ++++++++++++++++++++++- src/modules/auth/auth.docs.ts | 16 +++++++++++- src/modules/auth/auth.service.ts | 27 ++++++++++++++++++-- src/modules/auth/auth.types.ts | 11 ++++++-- src/modules/auth/auth.validators.ts | 7 ++++- src/modules/auth/routes/auth.v1.routes.ts | 9 +++++-- src/modules/user/user.model.ts | 11 ++++++++ 7 files changed, 103 insertions(+), 9 deletions(-) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 4efc8f8..123dffa 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,6 +1,11 @@ import { Request, Response, NextFunction } from "express"; import AuthService from "./auth.service"; -import { SignupInput, SignupOutput } from "./auth.types"; +import { + EmailVerificationOutput, + SendEmailVerificationCodeOutput, + SignupInput, + SignupOutput, +} from "./auth.types"; import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; @@ -28,3 +33,27 @@ export const signupOrganizationOwner = async ( next(err); } }; + +export const verifyEmailVerificationCode = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const result = await AuthService.verifyEmailVerificationCode( + req.body.emailVerificationCode, + req.body.userId, + ); + if (!result.success) + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Email verification failed", + ), + ); + return res + .status(200) + .json(result as ISuccessPayload); + } catch (err) { + next(err); + } +}; diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index 9789bcb..ca0a767 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -1,5 +1,9 @@ import { Tspec } from "tspec"; -import { SignupInput } from "./auth.validators"; +import { + EmailVerificationInput, + EmailVerificationOutput, + SignupInput, +} from "./auth.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; import { SignupOutput } from "./auth.types"; @@ -17,5 +21,15 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/verify-email": { + post: { + summary: "Verify a user's email with 6 digit code"; + body: EmailVerificationInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload & { details?: string }; + }; + }; + }; }; }>; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 43b67de..e7bef4d 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,6 +1,7 @@ import UserModel from "@modules/user/user.model"; import { EmailVerificationOutput, + SendEmailVerificationCodeOutput, SignupInput, SignupOutput, } from "./auth.types"; @@ -53,7 +54,8 @@ const AuthService = { userId: createdUser._id.toString(), organizationId: organization._id.toString(), emailSent: res.success - ? (res as ISuccessPayload).data.emailSent + ? (res as ISuccessPayload).data + .emailSent : false, }, }; @@ -65,7 +67,9 @@ const AuthService = { }, sendVerificationEmail: async ( user: IUser, - ): Promise | IErrorPayload> => { + ): Promise< + ISuccessPayload | IErrorPayload + > => { try { const code = user.generateEmailVerificationCode(); await user.save(); @@ -96,6 +100,25 @@ const AuthService = { return { success: false, error: err.message }; } }, + verifyEmailVerificationCode: async ( + code: string, + userId: string, + ): Promise | IErrorPayload> => { + const user = await UserModel.findOne({ + _id: userId, + }); + if (!user) return { success: false, error: "User not found" }; + if (user.isEmailVerified === true) + return { success: false, error: "Email already verified" }; + const isVerified = user.verifyEmailVerificationCode(code); + await user.save(); + if (!isVerified) + return { + success: false, + error: "Invalid or expired email verification code", + }; + return { success: true, data: { userId, isEmailVerified: true } }; + }, }; export default AuthService; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 9bb17ee..fb34c81 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -1,4 +1,4 @@ -import { signupSchema } from "./auth.validators"; +import { signupSchema, verifyEmailSchema } from "./auth.validators"; import { z } from "zod"; export type SignupInput = z.infer; @@ -9,4 +9,11 @@ export type SignupOutput = { emailSent: boolean; }; -export type EmailVerificationOutput = { emailSent: boolean }; +export type SendEmailVerificationCodeOutput = { emailSent: boolean }; + +export type EmailVerificationInput = z.infer; + +export type EmailVerificationOutput = { + userId: string; + isEmailVerified: boolean; +}; diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts index ff5b0f4..6458034 100644 --- a/src/modules/auth/auth.validators.ts +++ b/src/modules/auth/auth.validators.ts @@ -11,4 +11,9 @@ export const signupSchema = z.object({ organizationSize: z.number().int().min(1), }); -export type SignupInput = z.infer; +export const verifyEmailSchema = z.object({ + emailVerificationCode: z + .string() + .length(6, "Email verification code must be a 6 digit number"), + userId: z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid ObjectId"), +}); diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts index b21e94f..80f038d 100644 --- a/src/modules/auth/routes/auth.v1.routes.ts +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; -import { signupSchema } from "../auth.validators"; -import { signupOrganizationOwner } from "../auth.controller"; +import { signupSchema, verifyEmailSchema } from "../auth.validators"; +import { signupOrganizationOwner, verifyEmailVerificationCode } from "../auth.controller"; const authRouter = Router(); @@ -10,5 +10,10 @@ authRouter.post( validateResource(signupSchema), signupOrganizationOwner, ); +authRouter.post( + "/verify-email", + validateResource(verifyEmailSchema), + verifyEmailVerificationCode, +); export default authRouter; diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 5572dae..efee84d 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -56,6 +56,17 @@ userSchema.methods.generateEmailVerificationCode = function (): string { return code; }; +userSchema.methods.verifyEmailVerificationCode = function ( + code: string, +): boolean { + const isCorrectCode = hashWithCrypto(code) === this.emailVerificationCode; + const isNotExpiredCode = + new Date(this.emailVerificationCodeExpiry).getTime() > Date.now(); + this.emailVerificationCode = null; + this.emailVerificationCodeExpiry = null; + if (isCorrectCode && isNotExpiredCode) this.isEmailVerified = true; + return isCorrectCode && isNotExpiredCode; +}; const UserModel = mongoose.model("User", userSchema); From 4ac50ce62a008a367e62ca8ea565fc127289a7d9 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 12:37:45 +0100 Subject: [PATCH 25/48] test: integration tests for email verification --- .../integration/verifyEmail.v1.test.ts | 123 ++++++++++++++++++ .../__tests__/unit/verifyEmail.unit.test.ts | 8 ++ 2 files changed, 131 insertions(+) create mode 100644 src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts new file mode 100644 index 0000000..8ad32a3 --- /dev/null +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -0,0 +1,123 @@ +import request from "supertest"; +import app from "@app"; +import { UserFactory } from "@tests/factories/user.factory"; +import { OrganizationFactory } from "@tests/factories/organization.factory"; +import { sendEmailWithTemplate } from "@services/email.service"; +import UserModel from "@modules/user/user.model"; +import { convertTimeToMilliseconds } from "@utils/index"; +import { TIME_UNITS } from "@config/constants"; + +jest.mock("@services/email.service"); + +beforeEach(() => { + (sendEmailWithTemplate as jest.Mock).mockResolvedValue({ + success: true, + emailSent: true, + }); +}); + +function getVerificationCode() { + const call = (sendEmailWithTemplate as jest.Mock).mock.calls[0][0]; + return call.merge_info.emailVerificationCode; +} + +describe("Email Verification", () => { + it("should not verify user's email with invalid code", async () => { + const orgData = OrganizationFactory.generate(); + const user = { + ...UserFactory.generate(), + organizationName: orgData.name, + organizationSize: orgData.size, + }; + + await request(app).post("/api/v1/auth/signup").send(user); + + const verifyEmailRes = await request(app) + .post("/api/v1/auth/verify-email") + .send({ userEmail: user.email, emailVerificationCode: "RANDOM" }); + + expect(verifyEmailRes.status).toBe(400); + expect(verifyEmailRes.body.success).toBe(false); + }); + + it("should not verify user's email with expired code", async () => { + const orgData = OrganizationFactory.generate(); + const user = { + ...UserFactory.generate(), + organizationName: orgData.name, + organizationSize: orgData.size, + }; + + await request(app).post("/api/v1/auth/signup").send(user); + + const userInDb = await UserModel.findOne({ email: user.email }); + if (userInDb) { + userInDb.emailVerificationCodeExpiry = new Date( + Date.now() - convertTimeToMilliseconds(1, "min"), + ); + await userInDb.save(); + } + + const verifyEmailRes = await request(app) + .post("/api/v1/auth/verify-email") + .send({ + userEmail: user.email, + emailVerificationCode: getVerificationCode(), + }); + + expect(verifyEmailRes.status).toBe(400); + expect(verifyEmailRes.body.success).toBe(false); + }); + + it("should verify user's email after signup", async () => { + const orgData = OrganizationFactory.generate(); + const user = { + ...UserFactory.generate(), + organizationName: orgData.name, + organizationSize: orgData.size, + }; + + await request(app).post("/api/v1/auth/signup").send(user); + + const verifyEmailRes = await request(app) + .post("/api/v1/auth/verify-email") + .send({ + userEmail: user.email, + emailVerificationCode: getVerificationCode(), + }); + + expect(verifyEmailRes.status).toBe(200); + expect(verifyEmailRes.body.success).toBe(true); + }); + + it("should fail if user retries with the same code after being verified", async () => { + const orgData = OrganizationFactory.generate(); + const user = { + ...UserFactory.generate(), + organizationName: orgData.name, + organizationSize: orgData.size, + }; + + await request(app).post("/api/v1/auth/signup").send(user); + + const firstVerificationResponse = await request(app) + .post("/api/v1/auth/verify-email") + .send({ + userEmail: user.email, + emailVerificationCode: getVerificationCode(), + }); + + expect(firstVerificationResponse.status).toBe(200); + expect(firstVerificationResponse.body.success).toBe(true); + + const secondVerificationResponse = await request(app) + .post("/api/v1/auth/verify-email") + .send({ + userEmail: user.email, + emailVerificationCode: getVerificationCode(), + }); + + expect(secondVerificationResponse.status).toBe(400); + expect(secondVerificationResponse.body.success).toBe(false); + }); +}); diff --git a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts index 07700aa..10a2aa5 100644 --- a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts +++ b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts @@ -31,4 +31,12 @@ describe("Email Verification Code Logic", () => { expect(user.isEmailVerified).toBe(false); expect(isCorrectCode).toBe(false); }); + + it("Clears verification data after code is verified", async () => { + const user = new UserModel(UserFactory.generate()); + const code = user.generateEmailVerificationCode(); + expect(code).toHaveLength(6); + expect(code).not.toBe(user.emailVerificationCode); + expect(user.emailVerificationCodeExpiry).toBeDefined(); + }); }); From 2d11bfdf507ba989dd632c8a64390d301441921a Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 12:39:32 +0100 Subject: [PATCH 26/48] refactor: updates to email verification to ensure tests pass --- src/modules/auth/auth.controller.ts | 2 +- src/modules/auth/auth.docs.ts | 2 +- src/modules/auth/auth.service.ts | 8 +++++--- src/modules/auth/auth.types.ts | 2 +- src/modules/auth/auth.validators.ts | 2 +- src/modules/user/user.model.ts | 2 -- src/modules/user/user.types.ts | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 123dffa..e5c2a65 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -42,7 +42,7 @@ export const verifyEmailVerificationCode = async ( try { const result = await AuthService.verifyEmailVerificationCode( req.body.emailVerificationCode, - req.body.userId, + req.body.userEmail, ); if (!result.success) return next( diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index ca0a767..6393529 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -26,7 +26,7 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ summary: "Verify a user's email with 6 digit code"; body: EmailVerificationInput; responses: { - 201: ISuccessPayload; + 200: ISuccessPayload; 400: IErrorPayload & { details?: string }; }; }; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index e7bef4d..e5ef984 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -102,22 +102,24 @@ const AuthService = { }, verifyEmailVerificationCode: async ( code: string, - userId: string, + userEmail: string, ): Promise | IErrorPayload> => { const user = await UserModel.findOne({ - _id: userId, + email: userEmail, }); if (!user) return { success: false, error: "User not found" }; if (user.isEmailVerified === true) return { success: false, error: "Email already verified" }; const isVerified = user.verifyEmailVerificationCode(code); + user.emailVerificationCode = null; + user.emailVerificationCodeExpiry = null; await user.save(); if (!isVerified) return { success: false, error: "Invalid or expired email verification code", }; - return { success: true, data: { userId, isEmailVerified: true } }; + return { success: true, data: { userEmail, isEmailVerified: true } }; }, }; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index fb34c81..2611dcc 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -14,6 +14,6 @@ export type SendEmailVerificationCodeOutput = { emailSent: boolean }; export type EmailVerificationInput = z.infer; export type EmailVerificationOutput = { - userId: string; + userEmail: string; isEmailVerified: boolean; }; diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts index 6458034..6c2905c 100644 --- a/src/modules/auth/auth.validators.ts +++ b/src/modules/auth/auth.validators.ts @@ -15,5 +15,5 @@ export const verifyEmailSchema = z.object({ emailVerificationCode: z .string() .length(6, "Email verification code must be a 6 digit number"), - userId: z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid ObjectId"), + userEmail: z.string().email(), }); diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index efee84d..adb9607 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -62,8 +62,6 @@ userSchema.methods.verifyEmailVerificationCode = function ( const isCorrectCode = hashWithCrypto(code) === this.emailVerificationCode; const isNotExpiredCode = new Date(this.emailVerificationCodeExpiry).getTime() > Date.now(); - this.emailVerificationCode = null; - this.emailVerificationCodeExpiry = null; if (isCorrectCode && isNotExpiredCode) this.isEmailVerified = true; return isCorrectCode && isNotExpiredCode; }; diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index f244ff0..69d303b 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -14,8 +14,8 @@ export interface IUser extends mongoose.Document { createdAt: Date; updatedAt: Date; isEmailVerified: boolean; - emailVerificationCode: string; - emailVerificationCodeExpiry: Date; + emailVerificationCode?: string | null; + emailVerificationCodeExpiry?: Date | null; generateEmailVerificationCode: () => string; verifyEmailVerificationCode: (code: string) => boolean; } From 367d8caff2ea7735b7011d758599c1881ba453f1 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 13:34:59 +0100 Subject: [PATCH 27/48] test: resend verification email tests --- .../integration/verifyEmail.v1.test.ts | 46 ++++++++++++++++--- .../__tests__/unit/verifyEmail.unit.test.ts | 8 ++-- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts index 8ad32a3..879e993 100644 --- a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -16,8 +16,8 @@ beforeEach(() => { }); }); -function getVerificationCode() { - const call = (sendEmailWithTemplate as jest.Mock).mock.calls[0][0]; +function getVerificationCode(index = 0) { + const call = (sendEmailWithTemplate as jest.Mock).mock.calls[index][0]; return call.merge_info.emailVerificationCode; } @@ -34,7 +34,7 @@ describe("Email Verification", () => { const verifyEmailRes = await request(app) .post("/api/v1/auth/verify-email") - .send({ userEmail: user.email, emailVerificationCode: "RANDOM" }); + .send({ email: user.email, emailVerificationCode: "RANDOM" }); expect(verifyEmailRes.status).toBe(400); expect(verifyEmailRes.body.success).toBe(false); @@ -61,7 +61,7 @@ describe("Email Verification", () => { const verifyEmailRes = await request(app) .post("/api/v1/auth/verify-email") .send({ - userEmail: user.email, + email: user.email, emailVerificationCode: getVerificationCode(), }); @@ -82,7 +82,7 @@ describe("Email Verification", () => { const verifyEmailRes = await request(app) .post("/api/v1/auth/verify-email") .send({ - userEmail: user.email, + email: user.email, emailVerificationCode: getVerificationCode(), }); @@ -103,7 +103,7 @@ describe("Email Verification", () => { const firstVerificationResponse = await request(app) .post("/api/v1/auth/verify-email") .send({ - userEmail: user.email, + email: user.email, emailVerificationCode: getVerificationCode(), }); @@ -113,11 +113,43 @@ describe("Email Verification", () => { const secondVerificationResponse = await request(app) .post("/api/v1/auth/verify-email") .send({ - userEmail: user.email, + email: user.email, emailVerificationCode: getVerificationCode(), }); expect(secondVerificationResponse.status).toBe(400); expect(secondVerificationResponse.body.success).toBe(false); }); + it("resends verification code and previous code is different from new code", async () => { + const orgData = OrganizationFactory.generate(); + const user = { + ...UserFactory.generate(), + organizationName: orgData.name, + organizationSize: orgData.size, + }; + + await request(app).post("/api/v1/auth/signup").send(user); + + const resendVerificationCodeResponse = await request(app) + .post("/api/v1/auth/resend-verification-email") + .send({ + email: user.email, + }); + + expect(resendVerificationCodeResponse.status).toBe(200); + expect(getVerificationCode() === getVerificationCode(1)).toBeFalsy; + }); + it("cannot resend verification email to non existent user", async () => { + const user = { + ...UserFactory.generate(), + }; + + const resendVerificationCodeResponse = await request(app) + .post("/api/v1/auth/resend-verification-email") + .send({ + email: user.email, + }); + + expect(resendVerificationCodeResponse.status).toBe(400); + }); }); diff --git a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts index 10a2aa5..00d9c41 100644 --- a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts +++ b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts @@ -3,7 +3,7 @@ import { UserFactory } from "@tests/factories/user.factory"; import { convertTimeToMilliseconds } from "@utils/index"; describe("Email Verification Code Logic", () => { - it("Successfully generates a hashed email verification code", async () => { + it("successfully generates a hashed email verification code", async () => { const user = new UserModel(UserFactory.generate()); const code = user.generateEmailVerificationCode(); expect(code).toHaveLength(6); @@ -11,7 +11,7 @@ describe("Email Verification Code Logic", () => { expect(user.emailVerificationCodeExpiry).toBeDefined(); }); - it("Fails verification if code is wrong", async () => { + it("fails verification if code is wrong", async () => { const user = new UserModel(UserFactory.generate()); const code = user.generateEmailVerificationCode(); const isCorrectCode = user.verifyEmailVerificationCode( @@ -21,7 +21,7 @@ describe("Email Verification Code Logic", () => { expect(isCorrectCode).toBe(false); }); - it("Fails verification if code is expired", async () => { + it("fails verification if code is expired", async () => { const user = new UserModel(UserFactory.generate()); const code = user.generateEmailVerificationCode(); user.emailVerificationCodeExpiry = new Date( @@ -32,7 +32,7 @@ describe("Email Verification Code Logic", () => { expect(isCorrectCode).toBe(false); }); - it("Clears verification data after code is verified", async () => { + it("clears verification data after code is verified", async () => { const user = new UserModel(UserFactory.generate()); const code = user.generateEmailVerificationCode(); expect(code).toHaveLength(6); From 23af7677e2497ad64d4e311416cafed1b695d999 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 13:36:16 +0100 Subject: [PATCH 28/48] feat: add resend email verification endpoint --- src/modules/auth/auth.controller.ts | 27 ++++++++++++++++++++++- src/modules/auth/auth.docs.ts | 11 +++++++++ src/modules/auth/auth.service.ts | 6 ++--- src/modules/auth/auth.types.ts | 12 ++++++++-- src/modules/auth/auth.validators.ts | 6 ++++- src/modules/auth/routes/auth.v1.routes.ts | 17 ++++++++++++-- src/modules/user/user.service.ts | 10 +++++++++ 7 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 src/modules/user/user.service.ts diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index e5c2a65..97b1962 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -8,6 +8,7 @@ import { } from "./auth.types"; import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; +import UserService from "@modules/user/user.service"; export const signupOrganizationOwner = async ( req: Request, @@ -42,7 +43,7 @@ export const verifyEmailVerificationCode = async ( try { const result = await AuthService.verifyEmailVerificationCode( req.body.emailVerificationCode, - req.body.userEmail, + req.body.email, ); if (!result.success) return next( @@ -57,3 +58,27 @@ export const verifyEmailVerificationCode = async ( next(err); } }; + +export const resendEmailVerificationCode = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const user = await UserService.getUserByEmail(req.body.email); + if (!user) return next(AppError.badRequest("User not found")); + const result = await AuthService.sendVerificationEmail(user); + if (!result.success) + return next( + AppError.badRequest( + (result as IErrorPayload).error || + "Failed to resend email verification code", + ), + ); + return res + .status(200) + .json(result as ISuccessPayload); + } catch (err) { + next(err); + } +}; diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index 6393529..e1cf9ce 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -2,6 +2,7 @@ import { Tspec } from "tspec"; import { EmailVerificationInput, EmailVerificationOutput, + resendEmailVerificationCodeInput, SignupInput, } from "./auth.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; @@ -31,5 +32,15 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/resend-verification-email": { + post: { + summary: "Resend verification email"; + body: resendEmailVerificationCodeInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload & { details?: string }; + }; + }; + }; }; }>; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index e5ef984..a266168 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -102,10 +102,10 @@ const AuthService = { }, verifyEmailVerificationCode: async ( code: string, - userEmail: string, + email: string, ): Promise | IErrorPayload> => { const user = await UserModel.findOne({ - email: userEmail, + email: email, }); if (!user) return { success: false, error: "User not found" }; if (user.isEmailVerified === true) @@ -119,7 +119,7 @@ const AuthService = { success: false, error: "Invalid or expired email verification code", }; - return { success: true, data: { userEmail, isEmailVerified: true } }; + return { success: true, data: { email, isEmailVerified: true } }; }, }; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 2611dcc..064c9c2 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -1,4 +1,8 @@ -import { signupSchema, verifyEmailSchema } from "./auth.validators"; +import { + resendEmailVerificationCodeSchema, + signupSchema, + verifyEmailSchema, +} from "./auth.validators"; import { z } from "zod"; export type SignupInput = z.infer; @@ -14,6 +18,10 @@ export type SendEmailVerificationCodeOutput = { emailSent: boolean }; export type EmailVerificationInput = z.infer; export type EmailVerificationOutput = { - userEmail: string; + email: string; isEmailVerified: boolean; }; + +export type resendEmailVerificationCodeInput = z.infer< + typeof resendEmailVerificationCodeSchema +>; diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts index 6c2905c..2950b4e 100644 --- a/src/modules/auth/auth.validators.ts +++ b/src/modules/auth/auth.validators.ts @@ -15,5 +15,9 @@ export const verifyEmailSchema = z.object({ emailVerificationCode: z .string() .length(6, "Email verification code must be a 6 digit number"), - userEmail: z.string().email(), + email: z.string().email(), +}); + +export const resendEmailVerificationCodeSchema = z.object({ + email: z.string().email(), }); diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts index 80f038d..803ab80 100644 --- a/src/modules/auth/routes/auth.v1.routes.ts +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -1,7 +1,15 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; -import { signupSchema, verifyEmailSchema } from "../auth.validators"; -import { signupOrganizationOwner, verifyEmailVerificationCode } from "../auth.controller"; +import { + resendEmailVerificationCodeSchema, + signupSchema, + verifyEmailSchema, +} from "../auth.validators"; +import { + signupOrganizationOwner, + verifyEmailVerificationCode, + resendEmailVerificationCode, +} from "../auth.controller"; const authRouter = Router(); @@ -15,5 +23,10 @@ authRouter.post( validateResource(verifyEmailSchema), verifyEmailVerificationCode, ); +authRouter.post( + "/resend-verification-email", + validateResource(resendEmailVerificationCodeSchema), + resendEmailVerificationCode, +); export default authRouter; diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts new file mode 100644 index 0000000..b48de78 --- /dev/null +++ b/src/modules/user/user.service.ts @@ -0,0 +1,10 @@ +import UserModel from "@modules/user/user.model"; +import { IUser } from "@modules/user/user.types"; + +const UserService = { + getUserByEmail: async (email: string): Promise => { + return await UserModel.findOne({ email }); + }, +}; + +export default UserService; From 7da2cbad9e976ddbd01937ce43e0b9ebcacf86a3 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 13:46:30 +0100 Subject: [PATCH 29/48] fix: call toBeFalsy method to ensure check runs --- src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts index 879e993..97bced8 100644 --- a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -137,7 +137,7 @@ describe("Email Verification", () => { }); expect(resendVerificationCodeResponse.status).toBe(200); - expect(getVerificationCode() === getVerificationCode(1)).toBeFalsy; + expect(getVerificationCode() === getVerificationCode(1)).toBeFalsy(); }); it("cannot resend verification email to non existent user", async () => { const user = { From c52aa79c6c45a19929f2695d876b0198808ed94e Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 13:54:34 +0100 Subject: [PATCH 30/48] refactor: replace try/catch with routeTryCatcher helper --- src/middlewares/routeTryCatcher.ts | 6 ++++ src/modules/auth/auth.controller.ts | 43 +++++++++-------------------- 2 files changed, 19 insertions(+), 30 deletions(-) create mode 100644 src/middlewares/routeTryCatcher.ts diff --git a/src/middlewares/routeTryCatcher.ts b/src/middlewares/routeTryCatcher.ts new file mode 100644 index 0000000..49b8e5e --- /dev/null +++ b/src/middlewares/routeTryCatcher.ts @@ -0,0 +1,6 @@ +import { Request, Response, NextFunction } from "express"; + +export const routeTryCatcher = + (fn: Function) => (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 97b1962..5a351b2 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -9,13 +9,10 @@ import { import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; import UserService from "@modules/user/user.service"; +import { routeTryCatcher } from "@middlewares/routeTryCatcher"; -export const signupOrganizationOwner = async ( - req: Request, - res: Response, - next: NextFunction, -) => { - try { +export const signupOrganizationOwner = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { const input: SignupInput = req.body; const result = await AuthService.signupOwner(input); @@ -30,17 +27,11 @@ export const signupOrganizationOwner = async ( message: "Owner signup successful", data: (result as ISuccessPayload).data, }); - } catch (err) { - next(err); - } -}; + }, +); -export const verifyEmailVerificationCode = async ( - req: Request, - res: Response, - next: NextFunction, -) => { - try { +export const verifyEmailVerificationCode = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { const result = await AuthService.verifyEmailVerificationCode( req.body.emailVerificationCode, req.body.email, @@ -54,17 +45,11 @@ export const verifyEmailVerificationCode = async ( return res .status(200) .json(result as ISuccessPayload); - } catch (err) { - next(err); - } -}; + }, +); -export const resendEmailVerificationCode = async ( - req: Request, - res: Response, - next: NextFunction, -) => { - try { +export const resendEmailVerificationCode = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { const user = await UserService.getUserByEmail(req.body.email); if (!user) return next(AppError.badRequest("User not found")); const result = await AuthService.sendVerificationEmail(user); @@ -78,7 +63,5 @@ export const resendEmailVerificationCode = async ( return res .status(200) .json(result as ISuccessPayload); - } catch (err) { - next(err); - } -}; + }, +); From debbc401e991ce133f57a9df654c8490ecd4234a Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 13:54:43 +0100 Subject: [PATCH 31/48] refactor: replace try/catch with routeTryCatcher helper --- src/modules/auth/auth.controller.ts | 2 +- src/{middlewares => utils}/routeTryCatcher.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{middlewares => utils}/routeTryCatcher.ts (100%) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 5a351b2..e4d0d90 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -9,7 +9,7 @@ import { import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; import UserService from "@modules/user/user.service"; -import { routeTryCatcher } from "@middlewares/routeTryCatcher"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; export const signupOrganizationOwner = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { diff --git a/src/middlewares/routeTryCatcher.ts b/src/utils/routeTryCatcher.ts similarity index 100% rename from src/middlewares/routeTryCatcher.ts rename to src/utils/routeTryCatcher.ts From 94dafb113bf76dc47e1e805aa2decab8044dcd11 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 14:24:23 +0100 Subject: [PATCH 32/48] refactor: create clearVerificationEmailData method on user --- src/modules/auth/auth.service.ts | 23 +++++++++++++++-------- src/modules/user/user.model.ts | 6 ++++++ src/modules/user/user.types.ts | 1 + 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index a266168..ecbaf7d 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -45,7 +45,6 @@ const AuthService = { await createdUser.save({ session }); await organization.save({ session }); await session.commitTransaction(); - session.endSession(); const res = await AuthService.sendVerificationEmail(createdUser); return { @@ -61,8 +60,9 @@ const AuthService = { }; } catch (err) { await session.abortTransaction(); - session.endSession(); throw err; + } finally { + session.endSession(); } }, sendVerificationEmail: async ( @@ -107,18 +107,25 @@ const AuthService = { const user = await UserModel.findOne({ email: email, }); - if (!user) return { success: false, error: "User not found" }; + if (!user) + return { + success: false, + error: + "If this email exists in our system, a verification email has been sent", + }; if (user.isEmailVerified === true) - return { success: false, error: "Email already verified" }; + return { + success: false, + error: + "If this email exists in our system, a verification email has been sent", + }; const isVerified = user.verifyEmailVerificationCode(code); - user.emailVerificationCode = null; - user.emailVerificationCodeExpiry = null; - await user.save(); if (!isVerified) return { success: false, - error: "Invalid or expired email verification code", + error: "Verification failed. Please check your email and try again", }; + await user.clearEmailVerificationData(); return { success: true, data: { email, isEmailVerified: true } }; }, }; diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index adb9607..0fd46c3 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -66,6 +66,12 @@ userSchema.methods.verifyEmailVerificationCode = function ( return isCorrectCode && isNotExpiredCode; }; +userSchema.methods.clearEmailVerificationData = async function () { + this.emailVerificationCode = null; + this.emailVerificationCodeExpiry = null; + await this.save(); +}; + const UserModel = mongoose.model("User", userSchema); export default UserModel; diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index 69d303b..cdce7d1 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -18,4 +18,5 @@ export interface IUser extends mongoose.Document { emailVerificationCodeExpiry?: Date | null; generateEmailVerificationCode: () => string; verifyEmailVerificationCode: (code: string) => boolean; + clearEmailVerificationData: () => Promise; } From 1e5e633a9534c6e16f86b29733d559f6be300e40 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Fri, 14 Nov 2025 15:00:56 +0100 Subject: [PATCH 33/48] chore: remove redundant comment --- src/modules/user/user.model.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 0fd46c3..ce7b212 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -1,4 +1,3 @@ -// src/modules/auth/auth.model.ts import mongoose, { CallbackError, Schema } from "mongoose"; import { IUser } from "./user.types"; import { hashWithBcrypt, hashWithCrypto } from "@utils/encryptors"; From 43c718c42ea46e2ee6169cd8d512c6b6c7262d1e Mon Sep 17 00:00:00 2001 From: Exploit Date: Sat, 15 Nov 2025 13:19:51 +0100 Subject: [PATCH 34/48] Create node.js.yml --- .github/workflows/node.js.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/node.js.yml diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..6b4a5f7 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,31 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: Timesheets CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run build --if-present + - run: npm test From 9153842385f0bdde029b242753d9f757d73aaa4f Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 15 Nov 2025 13:22:41 +0100 Subject: [PATCH 35/48] chore: update ci script --- .github/workflows/node.js.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 6b4a5f7..cb92201 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,13 +5,12 @@ name: Timesheets CI on: push: - branches: [ "main" ] + branches: ["main", "develop"] pull_request: - branches: [ "main" ] + branches: ["main", "develop"] jobs: build: - runs-on: ubuntu-latest strategy: @@ -20,12 +19,12 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm test + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + - run: npm ci + - run: npm run build --if-present + - run: npm test From d20536771687b69579a45c11573e5d8aa847a65a Mon Sep 17 00:00:00 2001 From: Exploit Date: Sat, 15 Nov 2025 13:43:25 +0100 Subject: [PATCH 36/48] Create PULL_REQUEST_TEMPLATE.md --- .github/workflows/PULL_REQUEST_TEMPLATE.md | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/PULL_REQUEST_TEMPLATE.md diff --git a/.github/workflows/PULL_REQUEST_TEMPLATE.md b/.github/workflows/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..262b127 --- /dev/null +++ b/.github/workflows/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,37 @@ +# Pull Request Title +A clear, descriptive title that summarizes the changes. + +## Description +Please include a summary of the change and which issue is fixed. + +* Fixes: [Link to the issue or ticket being addressed, e.g., `closes #123`] + +### Type of change +Please delete options that are not relevant. + +* [ ] Bug fix (non-breaking change which fixes an issue) +* [ ] New feature (non-breaking change which adds functionality) +* [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +* [ ] Documentation update (changes to documentation files only) + +## How Has This Been Tested? +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. + +* Test A: [Description of how you tested, e.g., Ran unit tests locally] +* Test B: [Description of how you tested, e.g., Manually verified on staging environment] + +### Checklist: +* [ ] My code follows the style guidelines of this project +* [ ] I have performed a self-review of my own code +* [ ] I have commented my code, particularly in hard-to-understand areas +* [ ] I have made corresponding changes to the documentation +* [ ] My changes generate no new warnings +* [ ] I have added tests that prove my fix is effective or that the feature works +* [ ] New and existing unit tests pass locally with my changes +* [ ] Any dependent changes have been merged and published in downstream modules + +## Screenshots (if applicable) +[Add any relevant screenshots here to visually demonstrate changes, e.g. for UI updates] + +## Additional Notes +[Add any other context or information about the PR here] From 2dcee7fe68ae9a5390798337a516bd9c927dd832 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 15 Nov 2025 13:45:56 +0100 Subject: [PATCH 37/48] chore: update folder structure --- .github/{workflows => }/PULL_REQUEST_TEMPLATE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{workflows => }/PULL_REQUEST_TEMPLATE.md (100%) diff --git a/.github/workflows/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/workflows/PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md From 56c040bb012133bd61ff57a08e157f71930b7c27 Mon Sep 17 00:00:00 2001 From: Exploit Date: Sat, 15 Nov 2025 17:10:53 +0100 Subject: [PATCH 38/48] Husky Setup (#5) * chore: fix linting errors * chore: husky pre-commit setup --- .github/PULL_REQUEST_TEMPLATE.md | 37 +++++++++++-------- .husky/pre-commit | 5 +++ README.md | 22 ++++++----- eslint.config.js => eslint.config.mjs | 11 ++++-- package-lock.json | 17 +++++++++ package.json | 13 ++++++- src/app.ts | 10 ++--- src/config/constants.ts | 1 - src/docs/tspecGenerator.ts | 11 +++--- src/middlewares/errorHandler.ts | 30 +++++++++------ src/middlewares/validators.ts | 10 ++--- src/modules/auth/auth.service.ts | 6 +-- .../organization/organization.model.ts | 2 - src/server.ts | 2 - src/services/email.service.ts | 6 +-- src/tests/factories/organization.factory.ts | 4 +- src/tests/factories/user.factory.ts | 4 +- src/tests/fixtures/user.ts | 1 - src/tests/setup.ts | 3 +- src/types/index.ts | 11 ++++-- src/types/zeptomail.d.ts | 4 +- src/utils/index.ts | 2 +- src/utils/routeTryCatcher.ts | 3 +- 23 files changed, 133 insertions(+), 82 deletions(-) create mode 100755 .husky/pre-commit rename eslint.config.js => eslint.config.mjs (51%) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 262b127..9ed2821 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,37 +1,44 @@ # Pull Request Title + A clear, descriptive title that summarizes the changes. ## Description + Please include a summary of the change and which issue is fixed. -* Fixes: [Link to the issue or ticket being addressed, e.g., `closes #123`] +- Fixes: [Link to the issue or ticket being addressed, e.g., `closes #123`] ### Type of change + Please delete options that are not relevant. -* [ ] Bug fix (non-breaking change which fixes an issue) -* [ ] New feature (non-breaking change which adds functionality) -* [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -* [ ] Documentation update (changes to documentation files only) +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update (changes to documentation files only) ## How Has This Been Tested? + Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. -* Test A: [Description of how you tested, e.g., Ran unit tests locally] -* Test B: [Description of how you tested, e.g., Manually verified on staging environment] +- Test A: [Description of how you tested, e.g., Ran unit tests locally] +- Test B: [Description of how you tested, e.g., Manually verified on staging environment] ### Checklist: -* [ ] My code follows the style guidelines of this project -* [ ] I have performed a self-review of my own code -* [ ] I have commented my code, particularly in hard-to-understand areas -* [ ] I have made corresponding changes to the documentation -* [ ] My changes generate no new warnings -* [ ] I have added tests that prove my fix is effective or that the feature works -* [ ] New and existing unit tests pass locally with my changes -* [ ] Any dependent changes have been merged and published in downstream modules + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that the feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules ## Screenshots (if applicable) + [Add any relevant screenshots here to visually demonstrate changes, e.g. for UI updates] ## Additional Notes + [Add any other context or information about the PR here] diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..e1c12eb --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm test +npx lint-staged diff --git a/README.md b/README.md index 571439d..be1c72e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Built with **Express + TypeScript + MongoDB**, following best practices. --- ## Features (MVP) + - User authentication (JWT-based) - CRUD for Timesheets - CRUD for Projects @@ -15,6 +16,7 @@ Built with **Express + TypeScript + MongoDB**, following best practices. --- ## Tech Stack + - **Express** (TypeScript) - **MongoDB (Mongoose)** - **Jest + Supertest** (Testing) @@ -24,12 +26,14 @@ Built with **Express + TypeScript + MongoDB**, following best practices. --- ## 📦 Installation + ```bash git clone https://github.com/Timesheets-By-Exploit/backend.git -cd backend +cd backend npm install npm run dev ``` + --- ## Git Workflow @@ -115,12 +119,12 @@ git push Follow **Conventional Commits**: -* `feat:` → new feature -* `fix:` → bug fix -* `test:` → testing work -* `docs:` → documentation updates -* `refactor:` → code refactoring -* `chore:` → maintenance (configs, deps) +- `feat:` → new feature +- `fix:` → bug fix +- `test:` → testing work +- `docs:` → documentation updates +- `refactor:` → code refactoring +- `chore:` → maintenance (configs, deps) Examples: @@ -167,8 +171,8 @@ git branch -d feature/auth-signup git push origin --delete feature/auth-signup ``` - ### Folder Structure + ``` timesheets-backend/ │ @@ -191,7 +195,7 @@ timesheets-backend/ │ │ ├── module.model.ts │ │ ├── module.routes.ts │ │ └── module.types.ts -│ │ +│ │ │ │ │ ├── middlewares/ # Custom Express middlewares │ │ ├── errorHandler.ts diff --git a/eslint.config.js b/eslint.config.mjs similarity index 51% rename from eslint.config.js rename to eslint.config.mjs index 2182441..77168bd 100644 --- a/eslint.config.js +++ b/eslint.config.mjs @@ -1,5 +1,5 @@ -import js from "@eslint/js" -import tseslint from "typescript-eslint" +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; export default [ js.configs.recommended, @@ -12,7 +12,10 @@ export default [ }, }, rules: { - "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-explicit-any": "error", }, }, -] + { + ignores: ["dist/**", "node_modules/**", "src/modules/auth/__tests__/**"], + }, +]; diff --git a/package-lock.json b/package-lock.json index eb4fe4f..51f3ee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", + "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", "supertest": "^7.1.4", @@ -4740,6 +4741,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/package.json b/package.json index 0e310a5..464c829 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lint:check": "eslint . --ext .ts", "test": "jest --runInBand --detectOpenHandles", "test:unit": "jest --runInBand --detectOpenHandles --testPathPatterns=__tests__/unit", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "prepare": "husky install" }, "dependencies": { "@faker-js/faker": "^10.1.0", @@ -44,6 +45,14 @@ "ts-node-dev": "^2.0.0", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.1" + "typescript-eslint": "^8.46.1", + "husky": "^8.0.0" + }, + "lint-staged": { + "*.ts": [ + "eslint --fix", + "prettier --write", + "git add" + ] } } diff --git a/src/app.ts b/src/app.ts index ef26c19..409ad3e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,14 +19,10 @@ const app: Application = express(); app.get("/api/health", (req, res) => { res.send({ status: "ok" }); }); - app.use( - "/api/v1/docs", - swaggerUi.serve, - swaggerUi.setup(await getTSpec()), - ); + app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(await getTSpec())); app.use("*", notFound); - app.use((err: Error, req: Request, res: Response, _next: NextFunction) => - errorHandler(err, req, res), + app.use((err: Error, req: Request, res: Response, next: NextFunction) => + errorHandler(err, req, res, next), ); })(); diff --git a/src/config/constants.ts b/src/config/constants.ts index aea854b..50a0cff 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -9,4 +9,3 @@ export const TIME_UNITS = { secs: 1000, sec: 1000, }; - diff --git a/src/docs/tspecGenerator.ts b/src/docs/tspecGenerator.ts index 526d9ea..42b4005 100644 --- a/src/docs/tspecGenerator.ts +++ b/src/docs/tspecGenerator.ts @@ -1,5 +1,3 @@ - - import { generateTspec, Tspec } from "tspec"; const options: Tspec.GenerateParams = { @@ -10,12 +8,13 @@ const options: Tspec.GenerateParams = { openapi: { title: "Timesheets By Exploit", version: "1.0.0", - description: "This is the official documentation of the Timesheets By Exploit API", + description: + "This is the official documentation of the Timesheets By Exploit API", }, debug: false, - ignoreErrors: true, + ignoreErrors: true, }; -export default async function getTSpec(){ +export default async function getTSpec() { return await generateTspec(options); -} \ No newline at end of file +} diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index a5f5d9b..fe066e1 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,25 +1,31 @@ -import { Request, Response } from "express"; +import { NextFunction, Request, Response } from "express"; import AppError from "../utils/AppError"; import { NODE_ENV } from "@config/env"; -const errorHandler = (err: any, _req: Request, res: Response) => { - let statusCode = err.statusCode || 500; - let message = err.message || "Internal Server Error"; - let details = err.details; - +const errorHandler = ( + err: unknown, + _req: Request, + res: Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + next?: NextFunction, +) => { if (err instanceof AppError) { - return res.status(statusCode).json({ + return res.status(err.statusCode).json({ success: false, - error: message, - details, + error: err.message, + details: err.details, }); } + if (err instanceof Error) + return res.status(500).json({ + success: false, + error: err.message || "Something went wrong", + stack: NODE_ENV === "development" ? err.stack : "", + }); return res.status(500).json({ success: false, - error: message || "Something went wrong", - details, - stack: NODE_ENV === "development" ? err.stack : details, + error: "Something went wrong", }); }; diff --git a/src/middlewares/validators.ts b/src/middlewares/validators.ts index 027aa4c..f6c2714 100644 --- a/src/middlewares/validators.ts +++ b/src/middlewares/validators.ts @@ -1,4 +1,4 @@ -import { AnyZodObject } from "zod"; +import { AnyZodObject, ZodError, ZodIssue } from "zod"; import { Request, Response, NextFunction } from "express"; import AppError from "@utils/AppError"; @@ -8,15 +8,15 @@ const validateResource = try { schema.parse(req.body); next(); - } catch (e: any) { + } catch (e: unknown) { return next( AppError.badRequest( "Validation failed", - (e.errors as Array<{ path: string; message: string }>).reduce( - (acc: string, err: any, idx: number) => + (e as ZodError).errors.reduce( + (acc: string, err: ZodIssue, idx: number) => acc + `Error on path ${err.path}: ${err.message}${ - idx !== e.errors.length - 1 ? ", " : "" + idx !== (e as ZodError).errors.length - 1 ? ", " : "" }`, "", ), diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index ecbaf7d..8d6926e 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -73,7 +73,7 @@ const AuthService = { try { const code = user.generateEmailVerificationCode(); await user.save(); - let emailSentResponse = await sendEmailWithTemplate({ + const emailSentResponse = await sendEmailWithTemplate({ to: [ { email_address: { @@ -96,8 +96,8 @@ const AuthService = { success: emailSentResponse.success, data: { emailSent: emailSentResponse.emailSent || false }, }; - } catch (err: any) { - return { success: false, error: err.message }; + } catch (err) { + return { success: false, error: (err as Error).message }; } }, verifyEmailVerificationCode: async ( diff --git a/src/modules/organization/organization.model.ts b/src/modules/organization/organization.model.ts index cecf123..20e2607 100644 --- a/src/modules/organization/organization.model.ts +++ b/src/modules/organization/organization.model.ts @@ -36,7 +36,6 @@ const organizationSchema = new mongoose.Schema( { timestamps: true }, ); - organizationSchema.pre("validate", async function (next) { if (!this.isModified("name")) return next(); @@ -53,7 +52,6 @@ organizationSchema.pre("validate", async function (next) { next(); }); - const OrganizationModel = mongoose.model( "Organization", organizationSchema, diff --git a/src/server.ts b/src/server.ts index ef83fb6..a2c8c4a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,3 @@ - - import dotenv from "dotenv"; dotenv.config(); import app from "./app"; diff --git a/src/services/email.service.ts b/src/services/email.service.ts index e853afa..d2d324d 100644 --- a/src/services/email.service.ts +++ b/src/services/email.service.ts @@ -7,7 +7,7 @@ import { SUPPORT_EMAIL, } from "@config/env"; -let client = new SendMailClient({ +const client = new SendMailClient({ url: ZEPTO_MAIL_URL, token: ZEPTO_MAIL_TOKEN, }); @@ -28,7 +28,7 @@ export async function sendEmailWithTemplate( }, }); return { success: res.message === "OK", emailSent: res.message === "OK" }; - } catch (err: any) { - return { success: false, error: err.error.message }; + } catch (err) { + return { success: false, error: (err as { error: Error }).error.message }; } } diff --git a/src/tests/factories/organization.factory.ts b/src/tests/factories/organization.factory.ts index 9e1e975..0f86a4a 100644 --- a/src/tests/factories/organization.factory.ts +++ b/src/tests/factories/organization.factory.ts @@ -1,7 +1,9 @@ import { faker } from "@faker-js/faker"; export const OrganizationFactory = { - generate: (overrides: Partial<{}> = {}) => ({ + generate: ( + overrides: Partial<{ [x: string]: string | number | undefined }> = {}, + ) => ({ name: faker.company.name(), size: faker.number.int(), ...overrides, diff --git a/src/tests/factories/user.factory.ts b/src/tests/factories/user.factory.ts index f6b9819..b14cb34 100644 --- a/src/tests/factories/user.factory.ts +++ b/src/tests/factories/user.factory.ts @@ -1,7 +1,9 @@ import { faker } from "@faker-js/faker"; export const UserFactory = { - generate: (overrides: Partial<{}> = {}) => ({ + generate: ( + overrides: Partial<{ [x: string]: string | number | undefined }> = {}, + ) => ({ firstName: faker.person.firstName(), lastName: faker.person.lastName(), email: faker.internet.email(), diff --git a/src/tests/fixtures/user.ts b/src/tests/fixtures/user.ts index 7fca24a..88e38f8 100644 --- a/src/tests/fixtures/user.ts +++ b/src/tests/fixtures/user.ts @@ -1,4 +1,3 @@ - export const userFixtures = { noEmail: { firstName: "John", diff --git a/src/tests/setup.ts b/src/tests/setup.ts index 77ef08e..7bd7511 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -1,4 +1,3 @@ - import mongoose from "mongoose"; import { MongoMemoryReplSet } from "mongodb-memory-server"; @@ -12,7 +11,7 @@ beforeAll(async () => { beforeEach(async () => { const collections = await mongoose.connection.db?.collections(); - for (let collection of collections || []) { + for (const collection of collections || []) { await collection.deleteMany({}); } }); diff --git a/src/types/index.ts b/src/types/index.ts index 248c3e2..4ca1aa2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,10 @@ - -export type UNIT_OF_TIME = "minutes" | "min" | "hours" | "hr" | "seconds" | "sec"; +export type UNIT_OF_TIME = + | "minutes" + | "min" + | "hours" + | "hr" + | "seconds" + | "sec"; export interface IErrorPayload { success: boolean; @@ -8,4 +13,4 @@ export interface IErrorPayload { export interface ISuccessPayload { success: boolean; data: T; -} \ No newline at end of file +} diff --git a/src/types/zeptomail.d.ts b/src/types/zeptomail.d.ts index 27c0d17..8343597 100644 --- a/src/types/zeptomail.d.ts +++ b/src/types/zeptomail.d.ts @@ -47,7 +47,9 @@ declare module "zeptomail" { export class SendMailClient { constructor(options?: { url: string; token?: string } | string); - sendMailWithTemplate(payload: SendMailPayload): Promise; + sendMailWithTemplate( + payload: SendMailPayload, + ): Promise<{ message: string }>; } export { SendMailClient }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 8cd4b8e..75c46d6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -12,7 +12,7 @@ export function formatZodErrors( errors: Array<{ path: string; message: string }>, ) { return errors.reduce( - (acc: string, err: any, idx: number) => + (acc: string, err, idx: number) => acc + `Error on path ${err.path}: ${err.message}${ idx !== errors.length - 1 ? ", " : "" diff --git a/src/utils/routeTryCatcher.ts b/src/utils/routeTryCatcher.ts index 49b8e5e..b00a11a 100644 --- a/src/utils/routeTryCatcher.ts +++ b/src/utils/routeTryCatcher.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; export const routeTryCatcher = - (fn: Function) => (req: Request, res: Response, next: NextFunction) => { + (fn: (req: Request, res: Response, next: NextFunction) => void) => + (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch(next); }; From 9837e1c3f2a99233dbcc0c6c4e8bac45270a8205 Mon Sep 17 00:00:00 2001 From: Exploit Date: Sat, 27 Dec 2025 19:09:33 +0100 Subject: [PATCH 39/48] Login and Refresh token feature (#7) * feat: create login endpoint * tests: fix failing tests * refactor: auth utils refactoring * fix: documentation setup * feat: create get /me route * feat: create get /me route --- .env.example | 10 + jest.config.mjs | 1 + package-lock.json | 269 ++++++++++++++++-- package.json | 13 +- src/app.ts | 34 +-- src/config/constants.ts | 2 + src/config/env.ts | 6 + src/docs/tspecGenerator.ts | 2 +- src/middlewares/authenticate.ts | 37 +++ src/middlewares/errorHandler.ts | 9 +- src/middlewares/validators.ts | 5 +- .../__tests__/integration/login.v1.test.ts | 80 ++++++ .../auth/__tests__/integration/me.v1.test.ts | 198 +++++++++++++ .../integration/refreshToken.v1.test.ts | 232 +++++++++++++++ .../__tests__/integration/signup.v1.test.ts | 50 +++- .../integration/verifyEmail.v1.test.ts | 19 +- src/modules/auth/auth.controller.ts | 83 +++++- src/modules/auth/auth.docs.ts | 39 ++- src/modules/auth/auth.service.ts | 138 +++++++-- src/modules/auth/auth.types.ts | 47 ++- src/modules/auth/auth.validators.ts | 46 ++- src/modules/auth/refreshToken.model.ts | 24 ++ src/modules/auth/routes/auth.v1.routes.ts | 8 + src/modules/auth/utils/auth.cookies.ts | 56 ++++ src/modules/auth/utils/auth.tokens.ts | 148 ++++++++++ .../organization/organization.types.ts | 2 +- src/modules/user/user.service.ts | 4 + src/modules/user/user.utils.ts | 20 ++ src/server.ts | 15 +- src/tests/helpers/seed.ts | 37 +++ src/tests/setup.ts | 15 +- src/tests/utils.ts | 40 +++ src/types/express.d.ts | 9 + src/types/index.ts | 1 + src/utils/encryptors.ts | 3 + src/utils/generators.ts | 5 + 36 files changed, 1591 insertions(+), 116 deletions(-) create mode 100644 src/middlewares/authenticate.ts create mode 100644 src/modules/auth/__tests__/integration/login.v1.test.ts create mode 100644 src/modules/auth/__tests__/integration/me.v1.test.ts create mode 100644 src/modules/auth/__tests__/integration/refreshToken.v1.test.ts create mode 100644 src/modules/auth/refreshToken.model.ts create mode 100644 src/modules/auth/utils/auth.cookies.ts create mode 100644 src/modules/auth/utils/auth.tokens.ts create mode 100644 src/modules/user/user.utils.ts create mode 100644 src/tests/helpers/seed.ts create mode 100644 src/tests/utils.ts create mode 100644 src/types/express.d.ts diff --git a/.env.example b/.env.example index 391215d..bffb36a 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,13 @@ PORT=$PORT MONGODB_URI=$MONGODB_URI JWT_SECRET=$JWT_SECRET NODE_ENV=$NODE_ENV +JWT_REFRESH_EXPIRES_IN=$JWT_REFRESH_EXPIRES_IN +JWT_ACCESS_EXPIRES_IN=$JWT_ACCESS_EXPIRES_IN +REFRESH_TOKEN_BYTES=$REFRESH_TOKEN_BYTES +NODE_ENV=$NODE_ENV +ZEPTO_MAIL_TOKEN=$ZEPTO_MAIL_TOKEN +ZEPTO_MAIL_URL=$ZEPTO_MAIL_URL +FROM_EMAIL=$FROM_EMAIL +SUPPORT_EMAIL=$SUPPORT_EMAIL +FROM_NAME=$FROM_NAME +COOKIE_SECRET=$COOKIE_SECRET diff --git a/jest.config.mjs b/jest.config.mjs index 8afa0a0..c05de4e 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -8,6 +8,7 @@ export default { transform: { "^.+\\.(t|j)sx?$": "ts-jest", }, + maxWorkers: 1, transformIgnorePatterns: ["node_modules/(?!(.*@faker-js/faker))"], moduleNameMapper: { "^@app$": "/src/app.ts", diff --git a/package-lock.json b/package-lock.json index 51f3ee5..4daf452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,16 @@ "license": "UNLICENSED", "dependencies": { "@faker-js/faker": "^10.1.0", + "@types/cookie-parser": "^1.4.10", "@types/morgan": "^1.9.10", "bcryptjs": "^3.0.2", + "cookie": "^1.1.1", + "cookie-parser": "^1.4.7", + "cookie-signature": "^1.2.2", "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.19.2", + "jsonwebtoken": "^9.0.3", "mongoose": "^8.5.0", "morgan": "^1.10.1", "swagger-ui-express": "^5.0.1", @@ -25,15 +30,19 @@ }, "devDependencies": { "@eslint/js": "^9.37.0", + "@types/cookie": "^0.6.0", + "@types/cookie-signature": "^1.1.2", "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", + "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5", "ts-node-dev": "^2.0.0", @@ -978,9 +987,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -1295,9 +1304,9 @@ } }, "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -1769,7 +1778,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -1780,7 +1788,32 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/cookie-signature": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/cookie-signature/-/cookie-signature-1.1.2.tgz", + "integrity": "sha512-2OhrZV2LVnUAXklUFwuYUTokalh/dUb8rqt70OW6ByMSxYpauPZ+kfNLknX3aJyjY5iu8i3cUyoLZP9Fn37tTg==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1814,7 +1847,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -1827,7 +1859,6 @@ "version": "4.19.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1840,7 +1871,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/http-proxy": { @@ -1896,6 +1926,17 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1907,7 +1948,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/morgan": { @@ -1919,6 +1959,13 @@ "@types/node": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.7.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", @@ -1932,21 +1979,18 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1956,7 +2000,6 @@ "version": "1.15.9", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -1968,7 +2011,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -3100,6 +3142,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3389,20 +3437,55 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/cookie-signature": { + "node_modules/cookie-parser/node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", @@ -3608,6 +3691,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4025,6 +4117,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -5237,9 +5344,9 @@ } }, "node_modules/jest-config/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -5581,9 +5688,9 @@ } }, "node_modules/jest-runtime/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -5783,9 +5890,9 @@ "license": "MIT" }, "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==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5858,6 +5965,49 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kareem": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", @@ -5924,6 +6074,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5938,6 +6124,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6875,6 +7067,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "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": "30.2.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", @@ -7199,7 +7407,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index 464c829..77045b4 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,16 @@ }, "dependencies": { "@faker-js/faker": "^10.1.0", + "@types/cookie-parser": "^1.4.10", "@types/morgan": "^1.9.10", "bcryptjs": "^3.0.2", + "cookie": "^1.1.1", + "cookie-parser": "^1.4.7", + "cookie-signature": "^1.2.2", "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.19.2", + "jsonwebtoken": "^9.0.3", "mongoose": "^8.5.0", "morgan": "^1.10.1", "swagger-ui-express": "^5.0.1", @@ -32,21 +37,25 @@ }, "devDependencies": { "@eslint/js": "^9.37.0", + "@types/cookie": "^0.6.0", + "@types/cookie-signature": "^1.1.2", "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", + "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", + "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5", "ts-node-dev": "^2.0.0", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.1", - "husky": "^8.0.0" + "typescript-eslint": "^8.46.1" }, "lint-staged": { "*.ts": [ diff --git a/src/app.ts b/src/app.ts index 409ad3e..3d741c0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,27 +3,27 @@ import cors from "cors"; import morgan from "morgan"; import v1Router from "./routes/v1.route"; import errorHandler from "./middlewares/errorHandler"; +import cookieParser from "cookie-parser"; +import { COOKIE_SECRET } from "@config/env"; import swaggerUi from "swagger-ui-express"; -import { notFound } from "@middlewares/notFound"; -import getTSpec from "@docs/tspecGenerator"; - const app: Application = express(); -(async () => { - app.use(cors()); - app.use(express.json()); - app.use(morgan("dev")); +app.set("trust proxy", 1); +app.use(cors()); +app.use(cookieParser(COOKIE_SECRET)); +app.use(express.json()); +app.use(morgan("dev")); - app.use("/api/v1", v1Router); +app.use("/api/v1", v1Router); +export function mountSwagger(spec: object) { + app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(spec)); +} - app.get("/api/health", (req, res) => { - res.send({ status: "ok" }); - }); - app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(await getTSpec())); - app.use("*", notFound); - app.use((err: Error, req: Request, res: Response, next: NextFunction) => - errorHandler(err, req, res, next), - ); -})(); +app.get("/api/health", (req, res) => { + res.send({ status: "ok" }); +}); +app.use((err: Error, req: Request, res: Response, next: NextFunction) => + errorHandler(err, req, res, next), +); export default app; diff --git a/src/config/constants.ts b/src/config/constants.ts index 50a0cff..21db8b4 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -9,3 +9,5 @@ export const TIME_UNITS = { secs: 1000, sec: 1000, }; + +export const DEFAULT_REFRESH_DAYS = 30; diff --git a/src/config/env.ts b/src/config/env.ts index 66828f4..33719df 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -12,6 +12,8 @@ const envSchema = z.object({ FROM_EMAIL: z.string().email(), SUPPORT_EMAIL: z.string().email(), FROM_NAME: z.string(), + JWT_ACCESS_EXPIRES_IN: z.string(), + COOKIE_SECRET: z.string(), }); const isTest = process.env.NODE_ENV === "test"; @@ -29,6 +31,8 @@ const env = isTest SUPPORT_EMAIL: "", FROM_EMAIL: "", FROM_NAME: "", + JWT_ACCESS_EXPIRES_IN: "", + COOKIE_SECRET: "testsecret", }, error: envSchema.safeParse(process.env).error, } @@ -49,4 +53,6 @@ export const { FROM_EMAIL, FROM_NAME, SUPPORT_EMAIL, + JWT_ACCESS_EXPIRES_IN, + COOKIE_SECRET, } = env.data; diff --git a/src/docs/tspecGenerator.ts b/src/docs/tspecGenerator.ts index 42b4005..1fa2053 100644 --- a/src/docs/tspecGenerator.ts +++ b/src/docs/tspecGenerator.ts @@ -15,6 +15,6 @@ const options: Tspec.GenerateParams = { ignoreErrors: true, }; -export default async function getTSpec() { +export async function getTSpec() { return await generateTspec(options); } diff --git a/src/middlewares/authenticate.ts b/src/middlewares/authenticate.ts new file mode 100644 index 0000000..9644b63 --- /dev/null +++ b/src/middlewares/authenticate.ts @@ -0,0 +1,37 @@ +import { Request, Response, NextFunction } from "express"; +import AppError from "@utils/AppError"; +import { verifyAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserService from "@modules/user/user.service"; + +const authenticate = async ( + req: Request, + _res: Response, + next: NextFunction, +) => { + try { + const token = req.signedCookies?.access_token; + + if (!token) { + return next(AppError.unauthorized("Authentication required")); + } + + const payload = verifyAccessToken(token); + + const user = await UserService.getUserById(payload.id); + + if (!user) { + return next(AppError.unauthorized("User not found")); + } + + req.user = user; + next(); + } catch (err) { + return next( + AppError.unauthorized( + (err as Error).message || "Invalid or expired token", + ), + ); + } +}; + +export default authenticate; diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index fe066e1..bae7bac 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from "express"; import AppError from "../utils/AppError"; import { NODE_ENV } from "@config/env"; +import { MongooseError } from "mongoose"; const errorHandler = ( err: unknown, @@ -9,6 +10,9 @@ const errorHandler = ( // eslint-disable-next-line @typescript-eslint/no-unused-vars next?: NextFunction, ) => { + if (!(err instanceof AppError)) { + console.log(err); + } if (err instanceof AppError) { return res.status(err.statusCode).json({ success: false, @@ -16,13 +20,14 @@ const errorHandler = ( details: err.details, }); } - if (err instanceof Error) + + if (err instanceof Error || err instanceof MongooseError) { return res.status(500).json({ success: false, error: err.message || "Something went wrong", stack: NODE_ENV === "development" ? err.stack : "", }); - + } return res.status(500).json({ success: false, error: "Something went wrong", diff --git a/src/middlewares/validators.ts b/src/middlewares/validators.ts index f6c2714..788bce8 100644 --- a/src/middlewares/validators.ts +++ b/src/middlewares/validators.ts @@ -1,10 +1,9 @@ -import { AnyZodObject, ZodError, ZodIssue } from "zod"; +import { ZodSchema, ZodError, ZodIssue } from "zod"; import { Request, Response, NextFunction } from "express"; import AppError from "@utils/AppError"; const validateResource = - (schema: AnyZodObject) => - (req: Request, _res: Response, next: NextFunction) => { + (schema: ZodSchema) => (req: Request, _res: Response, next: NextFunction) => { try { schema.parse(req.body); next(); diff --git a/src/modules/auth/__tests__/integration/login.v1.test.ts b/src/modules/auth/__tests__/integration/login.v1.test.ts new file mode 100644 index 0000000..e9f6959 --- /dev/null +++ b/src/modules/auth/__tests__/integration/login.v1.test.ts @@ -0,0 +1,80 @@ +import request from "supertest"; +import app from "@app"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { clearDB } from "@tests/utils"; + +const verifiedUserEmail = "verified@example.com"; +const nonVerifiedUserEmail = "nonverified@example.com"; +const testPassword = "secret123"; + +beforeEach(async () => { + await clearDB(); +}); + +beforeEach(async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + await seedOneUserWithOrg({ + email: nonVerifiedUserEmail, + password: testPassword, + isEmailVerified: false, + }); +}); + +describe("Auth Login", () => { + it.only("should return 400 password is incorrect", async () => { + const res = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: "RANDOM", + }); + + expect(res.status).toBe(400); + }); + it.only("should return 400 if email is missing in request body", async () => { + const res = await request(app).post("/api/v1/auth/login").send({ + password: testPassword, + }); + expect(res.status).toBe(400); + }); + it.only("should return 400 if password is missing in request body", async () => { + const res = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + }); + expect(res.status).toBe(400); + }); + it.only("should return 403 if email is unverified", async () => { + const res = await request(app).post("/api/v1/auth/login").send({ + email: nonVerifiedUserEmail, + password: testPassword, + }); + expect(res.status).toBe(403); + }); + it.only("should return 200 and JWT when credentials are correct", async () => { + const res = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: testPassword, + }); + + expect(res.status).toBe(200); + + const cookies = res.headers["set-cookie"]; + expect(cookies).toBeDefined(); + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + const access = cookieArray.find((c) => c.startsWith("access_token=")); + const refresh = cookieArray.find((c) => c.startsWith("refresh_token=")); + + expect(access).toContain("HttpOnly"); + expect(access).toContain("SameSite=Lax"); + expect(access).toContain("Path=/"); + + expect(refresh).toContain("HttpOnly"); + expect(refresh).toContain("SameSite=Lax"); + expect(refresh).toContain("Path=/auth/refresh"); + + expect(res.body.success).toBe(true); + expect(res.body.data.user).toBeDefined(); + }); +}); diff --git a/src/modules/auth/__tests__/integration/me.v1.test.ts b/src/modules/auth/__tests__/integration/me.v1.test.ts new file mode 100644 index 0000000..289c7f8 --- /dev/null +++ b/src/modules/auth/__tests__/integration/me.v1.test.ts @@ -0,0 +1,198 @@ +import request from "supertest"; +import app from "@app"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { clearDB } from "@tests/utils"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserModel from "@modules/user/user.model"; +import * as signature from "cookie-signature"; +import { COOKIE_SECRET } from "@config/env"; +import * as jwt from "jsonwebtoken"; + +const verifiedUserEmail = "verified@example.com"; +const testPassword = "secret123"; + +beforeEach(async () => { + await clearDB(); +}); + +beforeEach(async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); +}); + +const createSignedAccessTokenCookie = (accessToken: string): string => { + const signedToken = "s:" + signature.sign(accessToken, COOKIE_SECRET); + return `access_token=${signedToken}`; +}; + +describe("GET /api/v1/auth/me", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).get("/api/v1/auth/me"); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Authentication required"); + }); + + it("should return 401 if access token is unsigned/invalid", async () => { + const res = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", ["access_token=invalid_token"]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if access token has invalid signature", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + // Sign with wrong secret + const wrongSignedToken = + "s:" + signature.sign(accessToken, "wrong_secret_key"); + + const res = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", [`access_token=${wrongSignedToken}`]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if access token JWT is malformed", async () => { + const malformedToken = "not.a.valid.jwt"; + const cookie = createSignedAccessTokenCookie(malformedToken); + + const res = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", [cookie]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if access token is expired", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + // Create an expired token + const expiredToken = jwt.sign( + { id: user._id.toString(), email: user.email }, + "testsecret", + { expiresIn: "-1h" }, + ); + + const cookie = createSignedAccessTokenCookie(expiredToken); + + const res = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", [cookie]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("expired"); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", [cookie]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + + it("should return 200 with user data when valid access token is provided", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toBeDefined(); + expect(res.body.data.user).toBeDefined(); + }); + + it("should return correct user data structure", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const { user: userData } = res.body.data; + + expect(userData.id).toBe(user._id.toString()); + expect(userData.email).toBe(user.email); + expect(userData.role).toBe(user.role); + expect(userData.isEmailVerified).toBe(user.isEmailVerified); + expect(userData.createdAt).toBeDefined(); + expect(userData.updatedAt).toBeDefined(); + + expect(userData.password).toBeUndefined(); + expect(userData.emailVerificationCode).toBeUndefined(); + expect(userData.emailVerificationCodeExpiry).toBeUndefined(); + }); + + it("should work with access token obtained from login flow", async () => { + const loginRes = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: testPassword, + }); + + expect(loginRes.status).toBe(200); + + const cookies = loginRes.headers["set-cookie"]; + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + const accessCookie = cookieArray.find((c) => c.startsWith("access_token=")); + + if (!accessCookie) throw new Error("Access token cookie not found"); + + const accessTokenValue = accessCookie.split(";")[0]; + + const meRes = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", [accessTokenValue]); + + expect(meRes.status).toBe(200); + expect(meRes.body.success).toBe(true); + expect(meRes.body.data.user.email).toBe(verifiedUserEmail); + }); +}); diff --git a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts new file mode 100644 index 0000000..a21ef9e --- /dev/null +++ b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts @@ -0,0 +1,232 @@ +import request from "supertest"; +import app from "@app"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { clearDB } from "@tests/utils"; +import { RefreshTokenModel } from "@modules/auth/refreshToken.model"; +import { hashWithCrypto } from "@utils/encryptors"; +import { generateRandomTokenWithCrypto } from "@utils/generators"; +import { convertTimeToMilliseconds } from "@utils/index"; +import UserModel from "@modules/user/user.model"; +import * as cookie from "cookie"; +import * as signature from "cookie-signature"; +import { COOKIE_SECRET } from "@config/env"; + +const verifiedUserEmail = "verified@example.com"; +const testPassword = "secret123"; + +beforeEach(async () => { + await clearDB(); +}); + +beforeEach(async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); +}); + +describe("Refresh Token", () => { + it("should return 401 if refresh token cookie is missing", async () => { + const res = await request(app).get("/api/v1/auth/refresh"); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if refresh token is invalid", async () => { + const res = await request(app) + .get("/api/v1/auth/refresh") + .set("Cookie", ["refresh_token=invalid_token"]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if refresh token is expired", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const rawRefreshToken = generateRandomTokenWithCrypto(64); + const tokenHash = hashWithCrypto(rawRefreshToken); + + await RefreshTokenModel.create({ + user: user._id, + tokenHash, + expiresAt: new Date(Date.now() - convertTimeToMilliseconds(1, "hr")), + }); + + const res = await request(app) + .get("/api/v1/auth/refresh") + .set("Cookie", [`refresh_token=${rawRefreshToken}`]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if refresh token is revoked", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const rawRefreshToken = generateRandomTokenWithCrypto(64); + const tokenHash = hashWithCrypto(rawRefreshToken); + + await RefreshTokenModel.create({ + user: user._id, + tokenHash, + expiresAt: new Date(Date.now() + convertTimeToMilliseconds(7 * 24, "hr")), + revokedAt: new Date(), + reason: "logout", + }); + + const res = await request(app) + .get("/api/v1/auth/refresh") + .set("Cookie", [`refresh_token=${rawRefreshToken}`]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should successfully refresh tokens with valid refresh token", async () => { + const loginRes = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: testPassword, + }); + expect(loginRes.status).toBe(200); + + const cookies = loginRes.headers["set-cookie"]; + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + const refreshCookie = cookieArray.find((c) => + c.startsWith("refresh_token="), + ); + if (!refreshCookie) throw new Error("Refresh token cookie not found"); + + const refreshToken = refreshCookie.split("=")[1].split(";")[0]; + const refreshRes = await request(app) + .get("/api/v1/auth/refresh") + .set("Cookie", [`refresh_token=${refreshToken}`]); + expect(refreshRes.status).toBe(200); + expect(refreshRes.body.success).toBe(true); + + const newCookies = refreshRes.headers["set-cookie"]; + expect(newCookies).toBeDefined(); + const newCookieArray = Array.isArray(newCookies) + ? newCookies + : [newCookies]; + const newAccess = newCookieArray.find((c) => c.startsWith("access_token=")); + const newRefresh = newCookieArray.find((c) => + c.startsWith("refresh_token="), + ); + + expect(newAccess).toBeDefined(); + expect(newRefresh).toBeDefined(); + expect(newAccess).toContain("HttpOnly"); + expect(newRefresh).toContain("HttpOnly"); + }); + + it("should revoke old token when new refresh token is issued", async () => { + const loginRes = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: testPassword, + }); + + expect(loginRes.status).toBe(200); + + const cookies = loginRes.headers["set-cookie"]; + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + + const refreshSetCookie = cookieArray.find((c) => + c.startsWith("refresh_token="), + ); + if (!refreshSetCookie) throw new Error("Refresh token cookie not found"); + + const parsed = cookie.parse(refreshSetCookie); + const signedValue = parsed.refresh_token; + + if (!signedValue?.startsWith("s:")) { + throw new Error( + `Expected signed refresh_token cookie, got: ${signedValue}`, + ); + } + + const refreshToken = signature.unsign( + signedValue.slice(2), // remove "s:" + COOKIE_SECRET, + ); + + if (!refreshToken) throw new Error("Invalid refresh_token signature"); + const oldTokenHash = hashWithCrypto(refreshToken); + const oldTokenDoc = await RefreshTokenModel.findOne({ + tokenHash: oldTokenHash, + }); + expect(oldTokenDoc).toBeTruthy(); + expect(oldTokenDoc?.revokedAt).toBeNull(); + + const refreshRes = await request(app) + .get("/api/v1/auth/refresh") + .set("Cookie", [`refresh_token=${signedValue}`]); + + expect(refreshRes.status).toBe(200); + + const revokedToken = await RefreshTokenModel.findOne({ + tokenHash: oldTokenHash, + }); + expect(revokedToken?.revokedAt).toBeTruthy(); + expect(revokedToken?.replacedByToken).toBeTruthy(); + }); + + it("should not allow reusing the same refresh token after it has been rotated", async () => { + const loginRes = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: testPassword, + }); + + expect(loginRes.status).toBe(200); + + const cookies = loginRes.headers["set-cookie"]; + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + const refreshCookie = cookieArray.find((c) => + c.startsWith("refresh_token="), + ); + + if (!refreshCookie) throw new Error("Refresh token cookie not found"); + + const refreshToken = refreshCookie.split("=")[1].split(";")[0]; + + // First refresh - should succeed + const firstRefreshRes = await request(app) + .get("/api/v1/auth/refresh") + .set("Cookie", [`refresh_token=${refreshToken}`]); + + expect(firstRefreshRes.status).toBe(200); + + // Try to reuse the old token - should fail + const secondRefreshRes = await request(app) + .get("/api/v1/auth/refresh") + .set("Cookie", [`refresh_token=${refreshToken}`]); + + expect(secondRefreshRes.status).toBe(401); + expect(secondRefreshRes.body.success).toBe(false); + }); + + it("should clear cookies when refresh token is invalid", async () => { + const res = await request(app) + .get("/api/v1/auth/refresh") + .set("Cookie", ["refresh_token=invalid_token"]); + + expect(res.status).toBe(401); + + const cookies = res.headers["set-cookie"]; + if (cookies) { + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + const clearedAccess = cookieArray.find( + (c) => c.includes("access_token=") && c.includes("Max-Age=0"), + ); + const clearedRefresh = cookieArray.find( + (c) => c.includes("refresh_token=") && c.includes("Max-Age=0"), + ); + + expect(clearedAccess || clearedRefresh).toBeTruthy(); + } + }); +}); diff --git a/src/modules/auth/__tests__/integration/signup.v1.test.ts b/src/modules/auth/__tests__/integration/signup.v1.test.ts index e0fe4fb..7382059 100644 --- a/src/modules/auth/__tests__/integration/signup.v1.test.ts +++ b/src/modules/auth/__tests__/integration/signup.v1.test.ts @@ -1,15 +1,23 @@ import request from "supertest"; import app from "@app"; +import mongoose from "mongoose"; import { userFixtures } from "@tests/fixtures/user"; import { UserFactory } from "@tests/factories/user.factory"; import { OrganizationFactory } from "@tests/factories/organization.factory"; +import { clearDB } from "@tests/utils"; + +beforeEach(async () => { + await clearDB(); +}); describe("Auth Signup", () => { - it("should return 400 if organization name is missing", async () => { + it("should return 400 if organization name is missing when createOrg is true", async () => { const res = await request(app) .post("/api/v1/auth/signup") .send({ ...UserFactory.generate(), + createOrg: true, + organizationSize: 10, }); expect(res.status).toBe(400); @@ -34,7 +42,7 @@ describe("Auth Signup", () => { expect(res.status).toBe(400); }); - it("should return 400 if last name is missing", async () => { + it("should return 400 if first name is missing", async () => { const res = await request(app) .post("/api/v1/auth/signup") .send({ @@ -64,22 +72,25 @@ describe("Auth Signup", () => { expect(res.status).toBe(400); }); - it("should return 400 if organization size is missing", async () => { + it("should return 400 if organization size is missing when createOrg is true", async () => { + const orgData = OrganizationFactory.generate(); const res = await request(app) .post("/api/v1/auth/signup") .send({ - ...userFixtures.invalidEmail, - ...OrganizationFactory.generate({ size: undefined }), + ...UserFactory.generate(), + createOrg: true, + organizationName: orgData.name, }); expect(res.status).toBe(400); }); - it("should return 201 when a user signs up successfully", async () => { + it("should return 201 when a user signs up successfully with organization", async () => { const orgData = OrganizationFactory.generate(); const res = await request(app) .post("/api/v1/auth/signup") .send({ ...UserFactory.generate(), + createOrg: true, organizationName: orgData.name, organizationSize: orgData.size, }); @@ -89,4 +100,31 @@ describe("Auth Signup", () => { expect(res.body.data).toHaveProperty("userId"); expect(res.body.data).toHaveProperty("organizationId"); }); + + it("should return 201 when a user signs up successfully without organization", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...UserFactory.generate(), + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("data"); + expect(res.body.data).toHaveProperty("userId"); + expect(res.body.data).not.toHaveProperty("organizationId"); + }); + + it("should return 201 when a user signs up with createOrg false", async () => { + const res = await request(app) + .post("/api/v1/auth/signup") + .send({ + ...UserFactory.generate(), + createOrg: false, + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("data"); + expect(res.body.data).toHaveProperty("userId"); + expect(res.body.data).not.toHaveProperty("organizationId"); + }); }); diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts index 97bced8..33d0653 100644 --- a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -5,10 +5,14 @@ import { OrganizationFactory } from "@tests/factories/organization.factory"; import { sendEmailWithTemplate } from "@services/email.service"; import UserModel from "@modules/user/user.model"; import { convertTimeToMilliseconds } from "@utils/index"; -import { TIME_UNITS } from "@config/constants"; +import { clearDB } from "@tests/utils"; jest.mock("@services/email.service"); +beforeEach(async () => { + await clearDB(); +}); + beforeEach(() => { (sendEmailWithTemplate as jest.Mock).mockResolvedValue({ success: true, @@ -26,16 +30,19 @@ describe("Email Verification", () => { const orgData = OrganizationFactory.generate(); const user = { ...UserFactory.generate(), + createOrg: true, organizationName: orgData.name, organizationSize: orgData.size, }; - await request(app).post("/api/v1/auth/signup").send(user); - + const signupResponse = await request(app) + .post("/api/v1/auth/signup") + .send(user); const verifyEmailRes = await request(app) .post("/api/v1/auth/verify-email") .send({ email: user.email, emailVerificationCode: "RANDOM" }); - + expect(signupResponse.status).toBe(201); + expect(signupResponse.body.success).toBe(true); expect(verifyEmailRes.status).toBe(400); expect(verifyEmailRes.body.success).toBe(false); }); @@ -44,6 +51,7 @@ describe("Email Verification", () => { const orgData = OrganizationFactory.generate(); const user = { ...UserFactory.generate(), + createOrg: true, organizationName: orgData.name, organizationSize: orgData.size, }; @@ -73,6 +81,7 @@ describe("Email Verification", () => { const orgData = OrganizationFactory.generate(); const user = { ...UserFactory.generate(), + createOrg: true, organizationName: orgData.name, organizationSize: orgData.size, }; @@ -94,6 +103,7 @@ describe("Email Verification", () => { const orgData = OrganizationFactory.generate(); const user = { ...UserFactory.generate(), + createOrg: true, organizationName: orgData.name, organizationSize: orgData.size, }; @@ -124,6 +134,7 @@ describe("Email Verification", () => { const orgData = OrganizationFactory.generate(); const user = { ...UserFactory.generate(), + createOrg: true, organizationName: orgData.name, organizationSize: orgData.size, }; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index e4d0d90..17674bd 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -2,6 +2,8 @@ import { Request, Response, NextFunction } from "express"; import AuthService from "./auth.service"; import { EmailVerificationOutput, + GetMeOutput, + LoginOutput, SendEmailVerificationCodeOutput, SignupInput, SignupOutput, @@ -10,6 +12,9 @@ import AppError from "@utils/AppError"; import { IErrorPayload, ISuccessPayload } from "src/types"; import UserService from "@modules/user/user.service"; import { routeTryCatcher } from "@utils/routeTryCatcher"; +import { compareHashedBcryptString } from "@utils/encryptors"; +import { serializeUser } from "@modules/user/user.utils"; +import { setAuthCookies } from "./utils/auth.cookies"; export const signupOrganizationOwner = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { @@ -24,7 +29,7 @@ export const signupOrganizationOwner = routeTryCatcher( return res.status(201).json({ success: true, - message: "Owner signup successful", + message: "Signup successful", data: (result as ISuccessPayload).data, }); }, @@ -65,3 +70,79 @@ export const resendEmailVerificationCode = routeTryCatcher( .json(result as ISuccessPayload); }, ); + +export const loginUser = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = await UserService.getUserByEmail(req.body.email); + if (!user) return next(AppError.badRequest("Invalid credentials")); + const isValidPassword = await compareHashedBcryptString( + req.body.password, + user.password, + ); + if (!isValidPassword) + return next(AppError.badRequest("Invalid credentials")); + if (user.isEmailVerified !== true) + return next(AppError.forbidden("Email not verified")); + const ip = req.ip; + const userAgent = req.get("User-Agent") || ""; + const result = await AuthService.createTokensForUser( + user, + String(req.body.rememberMe) === "true", + { ip, userAgent }, + ); + + setAuthCookies({ + res, + refreshToken: result.data.refreshToken, + refreshTokenExpiresAt: result.data.expiresAt, + accessToken: result.data.accessToken, + }); + + return res.json({ + success: true, + data: { + user: serializeUser(user), + }, + } as ISuccessPayload); + }, +); + +export const refreshToken = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const token = req.signedCookies?.refresh_token; + if (!token) return next(AppError.unauthorized("Unauthenticated")); + + const result = await AuthService.rotateRefreshToken(token, req.ip); + if (!result.success) { + res.clearCookie("access_token"); + res.clearCookie("refresh_token"); + return next(AppError.unauthorized(result.error || "Session expired")); + } + + setAuthCookies({ + res, + refreshToken: result.data.refreshToken, + refreshTokenExpiresAt: result.data.expiresAt, + accessToken: result.data.accessToken, + }); + + return res.json({ success: true }); + }, +); + +export const getCurrentUser = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user; + + if (!user) { + return next(AppError.unauthorized("User not found")); + } + + return res.json({ + success: true, + data: { + user: serializeUser(user), + }, + } as ISuccessPayload); + }, +); diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index e1cf9ce..fe6c057 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -2,6 +2,9 @@ import { Tspec } from "tspec"; import { EmailVerificationInput, EmailVerificationOutput, + GetMeOutput, + loginInput, + LoginOutput, resendEmailVerificationCodeInput, SignupInput, } from "./auth.types"; @@ -18,7 +21,7 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ body: SignupInput; responses: { 201: ISuccessPayload; - 400: IErrorPayload & { details?: string }; + 400: IErrorPayload; }; }; }; @@ -28,7 +31,7 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ body: EmailVerificationInput; responses: { 200: ISuccessPayload; - 400: IErrorPayload & { details?: string }; + 400: IErrorPayload; }; }; }; @@ -38,7 +41,37 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ body: resendEmailVerificationCodeInput; responses: { 200: ISuccessPayload; - 400: IErrorPayload & { details?: string }; + 400: IErrorPayload; + }; + }; + }; + "/login": { + post: { + summary: "Login User"; + body: loginInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/refresh": { + get: { + summary: "Refresh token"; + body: Record; + responses: { + 200: ISuccessPayload>; + 401: IErrorPayload; + }; + }; + }; + "/me": { + get: { + summary: "Get current authenticated user"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; }; }; }; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 8d6926e..a50fdce 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -10,6 +10,11 @@ import mongoose from "mongoose"; import { IUser } from "@modules/user/user.types"; import { sendEmailWithTemplate } from "@services/email.service"; import { ISuccessPayload, IErrorPayload } from "src/types"; +import { hashWithCrypto } from "@utils/encryptors"; +import { RefreshTokenModel } from "./refreshToken.model"; +import { DEFAULT_REFRESH_DAYS } from "@config/constants"; +import { generateRandomTokenWithCrypto } from "@utils/generators"; +import { generateAccessToken, rotateRefreshToken } from "./utils/auth.tokens"; const AuthService = { signupOwner: async ( @@ -20,50 +25,61 @@ const AuthService = { lastName, email, password, + createOrg = false, organizationName, organizationSize, } = input; const existingUser = await UserModel.exists({ email }); if (existingUser) return { success: false, error: "User already exists" }; - + let createdUser; + let organization; const session = await mongoose.startSession(); session.startTransaction(); try { - const createdUser = new UserModel({ + createdUser = new UserModel({ firstName, lastName, email, password, role: "owner", }); - const organization = new OrganizationModel({ - name: organizationName, - owner: createdUser._id, - size: organizationSize, - }); - createdUser.organization = organization._id; + + if (createOrg) { + if (!organizationName || organizationSize === undefined) { + throw new Error("Organization name and size are required"); + } + organization = new OrganizationModel({ + name: organizationName, + owner: createdUser._id, + size: organizationSize, + }); + createdUser.organization = organization._id; + await organization.save({ session }); + } + await createdUser.save({ session }); - await organization.save({ session }); await session.commitTransaction(); - const res = await AuthService.sendVerificationEmail(createdUser); - - return { - success: true, - data: { - userId: createdUser._id.toString(), - organizationId: organization._id.toString(), - emailSent: res.success - ? (res as ISuccessPayload).data - .emailSent - : false, - }, - }; } catch (err) { - await session.abortTransaction(); + if (session.inTransaction()) { + await session.abortTransaction(); + } throw err; } finally { session.endSession(); } + const res = await AuthService.sendVerificationEmail(createdUser); + + return { + success: true, + data: { + userId: createdUser._id.toString(), + ...(organization && { organizationId: organization._id.toString() }), + emailSent: res.success + ? (res as ISuccessPayload).data + .emailSent + : false, + }, + }; }, sendVerificationEmail: async ( user: IUser, @@ -128,6 +144,82 @@ const AuthService = { await user.clearEmailVerificationData(); return { success: true, data: { email, isEmailVerified: true } }; }, + createTokensForUser: async ( + user: IUser, + rememberMe = false, + metaData?: { + ip?: string | undefined; + userAgent?: string | undefined; + }, + ) => { + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const rawRefreshToken = generateRandomTokenWithCrypto( + Number(process.env.REFRESH_TOKEN_BYTES || 64), + ); + const tokenHash = hashWithCrypto(rawRefreshToken); + + const expiresAt = new Date( + Date.now() + + (rememberMe ? DEFAULT_REFRESH_DAYS : 7) * 24 * 60 * 60 * 1000, + ); + + const refreshDoc = await RefreshTokenModel.create({ + user: user._id, + tokenHash, + expiresAt, + createdByIp: metaData?.ip, + userAgent: metaData?.userAgent, + }); + + return { + success: true, + data: { + accessToken, + refreshToken: rawRefreshToken, + refreshTokenId: refreshDoc._id, + expiresAt, + }, + }; + }, + rotateRefreshToken: async ( + rawRefreshToken: string, + ip?: string, + ): Promise< + | { + success: true; + data: { + refreshToken: string; + refreshTokenId: string | undefined; + accessToken: string; + expiresAt: Date; + }; + } + | { success: false; error: string } + > => { + try { + const { refreshToken, refreshTokenId, expiresAt, accessToken } = + await rotateRefreshToken(rawRefreshToken, ip); + + return { + success: true, + data: { + refreshToken, + refreshTokenId: refreshTokenId?.toString(), + accessToken, + expiresAt, + }, + }; + } catch (err) { + return { + success: false, + error: (err as unknown as Error).message, + }; + } + }, }; export default AuthService; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 064c9c2..a3436ad 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -1,14 +1,29 @@ +import mongoose from "mongoose"; import { + loginSchema, resendEmailVerificationCodeSchema, signupSchema, verifyEmailSchema, } from "./auth.validators"; import { z } from "zod"; +export interface IRefreshTokenDoc extends Document { + user: mongoose.Types.ObjectId; + tokenHash: string; // sha256(token) + expiresAt: Date; + createdAt: Date; + createdByIp?: string; + userAgent?: string; + revokedAt?: Date | null; + revokedByIp?: string | null; + replacedByToken?: string | null; + reason?: string | null; +} + export type SignupInput = z.infer; export type SignupOutput = { - organizationId: string; + organizationId?: string; userId: string; emailSent: boolean; }; @@ -25,3 +40,33 @@ export type EmailVerificationOutput = { export type resendEmailVerificationCodeInput = z.infer< typeof resendEmailVerificationCodeSchema >; + +export type LoginOutput = { + user: { + email: string; + isEmailVerified: boolean; + id: string; + name: string; + role: string; + createdAt: string; + updatedAt: string; + }; +}; +export type loginInput = z.infer; + +export interface AccessPayload { + id: string; + email: string; +} + +export type GetMeOutput = { + user: { + id: string; + email: string; + name: string; + role: string; + isEmailVerified: boolean; + createdAt: Date; + updatedAt: Date; + }; +}; diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts index 2950b4e..0b5e70c 100644 --- a/src/modules/auth/auth.validators.ts +++ b/src/modules/auth/auth.validators.ts @@ -1,15 +1,36 @@ import { z } from "zod"; -export const signupSchema = z.object({ - firstName: z.string().min(2, "Name must be at least 2 characters long"), - lastName: z.string().min(2, "Name must be at least 2 characters long"), - email: z.string().email("Invalid email format"), - password: z.string().min(6, "Password must be at least 6 characters"), - organizationName: z - .string() - .min(2, "Organization name must be at least 2 characters"), - organizationSize: z.number().int().min(1), -}); +export const signupSchema = z + .object({ + firstName: z.string().min(2, "Name must be at least 2 characters long"), + lastName: z.string().min(2, "Name must be at least 2 characters long"), + email: z.string().email("Invalid email format"), + password: z.string().min(6, "Password must be at least 6 characters"), + createOrg: z.boolean().optional().default(false), + organizationName: z + .string() + .min(2, "Organization name must be at least 2 characters") + .optional(), + organizationSize: z.number().int().min(1).optional(), + }) + .superRefine((data, ctx) => { + if (data.createOrg) { + if (!data.organizationName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Organization name is required when createOrg is true", + path: ["organizationName"], + }); + } + if (data.organizationSize === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Organization size is required when createOrg is true", + path: ["organizationSize"], + }); + } + } + }); export const verifyEmailSchema = z.object({ emailVerificationCode: z @@ -21,3 +42,8 @@ export const verifyEmailSchema = z.object({ export const resendEmailVerificationCodeSchema = z.object({ email: z.string().email(), }); + +export const loginSchema = z.object({ + email: z.string().email("Invalid email format"), + password: z.string(), +}); diff --git a/src/modules/auth/refreshToken.model.ts b/src/modules/auth/refreshToken.model.ts new file mode 100644 index 0000000..35d24b9 --- /dev/null +++ b/src/modules/auth/refreshToken.model.ts @@ -0,0 +1,24 @@ +import mongoose, { Schema } from "mongoose"; +import { IRefreshTokenDoc } from "./auth.types"; + +const RefreshTokenSchema = new Schema({ + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + tokenHash: { type: String, required: true, index: true, unique: true }, + expiresAt: { type: Date, required: true }, + createdAt: { type: Date, default: () => new Date() }, + createdByIp: { type: String }, + userAgent: { type: String }, + revokedAt: { type: Date, default: null }, + revokedByIp: { type: String, default: null }, + replacedByToken: { + type: Schema.Types.ObjectId, + ref: "RefreshToken", + default: null, + }, + reason: { type: String, default: null }, +}); + +export const RefreshTokenModel = mongoose.model( + "RefreshToken", + RefreshTokenSchema, +); diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts index 803ab80..0753dbe 100644 --- a/src/modules/auth/routes/auth.v1.routes.ts +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -1,6 +1,8 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; +import authenticate from "@middlewares/authenticate"; import { + loginSchema, resendEmailVerificationCodeSchema, signupSchema, verifyEmailSchema, @@ -9,6 +11,9 @@ import { signupOrganizationOwner, verifyEmailVerificationCode, resendEmailVerificationCode, + loginUser, + refreshToken, + getCurrentUser, } from "../auth.controller"; const authRouter = Router(); @@ -28,5 +33,8 @@ authRouter.post( validateResource(resendEmailVerificationCodeSchema), resendEmailVerificationCode, ); +authRouter.post("/login", validateResource(loginSchema), loginUser); +authRouter.get("/refresh", refreshToken); +authRouter.get("/me", authenticate, getCurrentUser); export default authRouter; diff --git a/src/modules/auth/utils/auth.cookies.ts b/src/modules/auth/utils/auth.cookies.ts new file mode 100644 index 0000000..c9fc5b8 --- /dev/null +++ b/src/modules/auth/utils/auth.cookies.ts @@ -0,0 +1,56 @@ +import { convertTimeToMilliseconds } from "@utils/index"; +import { Response } from "express"; + +export function setAuthCookies({ + res, + accessToken, + refreshToken, + refreshTokenExpiresAt, +}: { + res: Response; + accessToken: string; + refreshToken: string; + refreshTokenExpiresAt: Date; +}) { + const secure = process.env.NODE_ENV === "production"; + const sameSite = secure ? "none" : "lax"; + + res.cookie("access_token", accessToken, { + httpOnly: true, + secure, + sameSite, + maxAge: convertTimeToMilliseconds(15, "min"), + path: "/", + signed: true, + }); + + res.cookie("refresh_token", refreshToken, { + httpOnly: true, + secure, + sameSite, + maxAge: Math.max(0, refreshTokenExpiresAt.getTime() - Date.now()), + path: "/auth/refresh", + signed: true, + }); +} + +export function clearAuthCookies(res: Response) { + const secure = process.env.NODE_ENV === "production"; + const sameSite = secure ? "none" : "lax"; + + res.clearCookie("access_token", { + httpOnly: true, + secure, + sameSite, + path: "/", + signed: true, + }); + + res.clearCookie("refresh_token", { + httpOnly: true, + secure, + sameSite, + path: "/auth/refresh", + signed: true, + }); +} diff --git a/src/modules/auth/utils/auth.tokens.ts b/src/modules/auth/utils/auth.tokens.ts new file mode 100644 index 0000000..ba90fab --- /dev/null +++ b/src/modules/auth/utils/auth.tokens.ts @@ -0,0 +1,148 @@ +import { JWT_ACCESS_EXPIRES_IN, JWT_SECRET } from "@config/env"; +import * as jwt from "jsonwebtoken"; +import mongoose from "mongoose"; +import { generateRandomTokenWithCrypto } from "@utils/generators"; +import { hashWithCrypto } from "@utils/encryptors"; +import { IUser } from "@modules/user/user.types"; +import { AccessPayload } from "../auth.types"; +import { DEFAULT_REFRESH_DAYS } from "@config/constants"; +import UserModel from "@modules/user/user.model"; +import { convertTimeToMilliseconds } from "@utils/index"; +import { RefreshTokenModel } from "../refreshToken.model"; + +export function verifyAccessToken(token: string): AccessPayload { + return jwt.verify(token, JWT_SECRET) as AccessPayload; +} + +export async function createTokensForUser( + user: IUser, + rememberMe = false, + ip?: string, + userAgent?: string, +) { + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const rawRefreshToken = generateRandomTokenWithCrypto( + Number(process.env.REFRESH_TOKEN_BYTES || 64), + ); + const tokenHash = hashWithCrypto(rawRefreshToken); + + const expiresAt = new Date( + Date.now() + (rememberMe ? DEFAULT_REFRESH_DAYS : 7) * 24 * 60 * 60 * 1000, + ); + + const refreshDoc = await RefreshTokenModel.create({ + user: user._id, + tokenHash, + expiresAt, + createdByIp: ip, + userAgent, + }); + + return { + accessToken, + refreshToken: rawRefreshToken, + refreshTokenId: refreshDoc._id, + expiresAt, + }; +} + +export async function rotateRefreshToken( + oldToken: string, + ip?: string, + userAgent?: string, +) { + const oldHash = hashWithCrypto(oldToken); + const existing = await RefreshTokenModel.findOne({ tokenHash: oldHash }); + + if (!existing || existing.revokedAt) { + if (existing && existing.revokedAt) { + await RefreshTokenModel.updateMany( + { user: existing.user, revokedAt: null }, + { revokedAt: new Date(), reason: "reused" }, + ); + } + throw new Error("Invalid token"); + } + + if (existing.expiresAt < new Date()) { + await existing.updateOne({ + revokedAt: new Date(), + reason: "expired", + }); + throw new Error("Refresh token expired"); + } + const user = await UserModel.findById(existing.user); + if (!user) throw new Error("User not found!"); + const rawRefreshToken = generateRandomTokenWithCrypto( + Number(process.env.REFRESH_TOKEN_BYTES || 64), + ); + const newHash = hashWithCrypto(rawRefreshToken); + const expiresAt = new Date( + Date.now() + convertTimeToMilliseconds(720, "hours"), + ); + + const session = await mongoose.startSession(); + try { + session.startTransaction(); + + const newRefreshToken = await RefreshTokenModel.create( + [ + { + user: existing.user, + tokenHash: newHash, + expiresAt, + createdByIp: ip, + userAgent, + }, + ], + { session }, + ); + + existing.revokedAt = new Date(); + existing.revokedByIp = ip as string; + existing.replacedByToken = newRefreshToken[0]?._id?.toString() as string; + await existing.save({ session }); + + await session.commitTransaction(); + + return { + refreshToken: rawRefreshToken, + refreshTokenId: newRefreshToken[0]?._id, + accessToken: generateAccessToken({ + id: user._id.toString(), + email: user?.email, + }), + expiresAt, + }; + } catch (err) { + if (session.inTransaction()) { + await session.abortTransaction(); + } + throw err; + } finally { + session.endSession(); + } +} + +export async function revokeRefreshToken(token: string | null, ip?: string) { + if (!token) return; + const tokenHash = hashWithCrypto(token); + await RefreshTokenModel.findOneAndUpdate( + { tokenHash }, + { revokedAt: new Date(), revokedByIp: ip, reason: "logout" }, + ); +} + +export function generateAccessToken(payload: AccessPayload) { + return jwt.sign( + payload, + JWT_SECRET as string, + { + expiresIn: (JWT_ACCESS_EXPIRES_IN as string) || "15m", + } as jwt.SignOptions, + ); +} diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index af7e6a1..2e0eb88 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -6,7 +6,7 @@ export interface IOrganization extends mongoose.Document { _id: mongoose.Types.ObjectId; name: string; slug: string; - owner: mongoose.Schema.Types.ObjectId; + owner: mongoose.Types.ObjectId; domain?: string; description?: string; createdAt: Date; diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index b48de78..7c55f2e 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -5,6 +5,10 @@ const UserService = { getUserByEmail: async (email: string): Promise => { return await UserModel.findOne({ email }); }, + + getUserById: async (id: string): Promise => { + return await UserModel.findById(id); + }, }; export default UserService; diff --git a/src/modules/user/user.utils.ts b/src/modules/user/user.utils.ts new file mode 100644 index 0000000..90a9f39 --- /dev/null +++ b/src/modules/user/user.utils.ts @@ -0,0 +1,20 @@ +import { IUser } from "./user.types"; + +export function serializeUser(user: IUser) { + if (!user) return null; + + const obj = + typeof user.toObject === "function" ? user.toObject() : { ...user }; + + const safe = { + id: obj._id?.toString(), + email: obj.email, + name: obj.name, + role: obj.role, + isEmailVerified: obj.isEmailVerified, + createdAt: obj.createdAt, + updatedAt: obj.updatedAt, + }; + + return safe; +} diff --git a/src/server.ts b/src/server.ts index a2c8c4a..5c8f70c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,20 @@ import dotenv from "dotenv"; dotenv.config(); -import app from "./app"; +import app, { mountSwagger } from "./app"; import connectDB from "./config/db"; import { PORT } from "@config/env"; +import { getTSpec } from "@docs/tspecGenerator"; +import { notFound } from "@middlewares/notFound"; + +async function start() { + await connectDB(); + const spec = await getTSpec(); // async is allowed here + mountSwagger(spec); + app.use("*", notFound); -connectDB().then(() => { app.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`); }); -}); +} + +start(); diff --git a/src/tests/helpers/seed.ts b/src/tests/helpers/seed.ts new file mode 100644 index 0000000..e88a576 --- /dev/null +++ b/src/tests/helpers/seed.ts @@ -0,0 +1,37 @@ +import UserModel from "@modules/user/user.model"; +import OrganizationModel from "@modules/organization/organization.model"; +import { UserFactory } from "@tests/factories/user.factory"; +import { OrganizationFactory } from "@tests/factories/organization.factory"; +import { IUser } from "@modules/user/user.types"; +import { IOrganization } from "@modules/organization/organization.types"; +import { retryOperation } from "@tests/utils"; + +export const seedOneUserWithOrg = async ( + userOverrides: Partial | undefined = {}, + orgOverrides: Partial | undefined = {}, +) => { + return retryOperation(async () => { + const organization = new OrganizationModel({ + ...OrganizationFactory.generate(), + ...orgOverrides, + }); + + const userData = { + ...UserFactory.generate(), + organization: organization._id, + isEmailVerified: true, + ...userOverrides, + }; + + const user = new UserModel(userData); + organization.owner = user._id; + await organization.save(); + await user.save(); + return { user, organization }; + }); +}; + +export const clearUsersAndOrgs = async () => { + await retryOperation(() => UserModel.deleteMany({})); + await retryOperation(() => OrganizationModel.deleteMany({})); +}; diff --git a/src/tests/setup.ts b/src/tests/setup.ts index 7bd7511..4e24a7b 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -4,19 +4,18 @@ import { MongoMemoryReplSet } from "mongodb-memory-server"; let mongo: MongoMemoryReplSet; beforeAll(async () => { - mongo = await MongoMemoryReplSet.create(); + mongo = await MongoMemoryReplSet.create({ + replSet: { count: 1 }, + }); const uri = mongo.getUri(); await mongoose.connect(uri); -}, 120000); +}); -beforeEach(async () => { - const collections = await mongoose.connection.db?.collections(); - for (const collection of collections || []) { - await collection.deleteMany({}); - } +afterEach(async () => { + await mongoose.connection.db?.command({ ping: 1 }).catch(() => {}); }); afterAll(async () => { await mongoose.connection.close(); if (mongo) await mongo.stop(); -}, 120000); +}); diff --git a/src/tests/utils.ts b/src/tests/utils.ts new file mode 100644 index 0000000..6ea3bf3 --- /dev/null +++ b/src/tests/utils.ts @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +const MAX_RETRIES = 8; +const BASE_DELAY_MS = 50; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const retryOperation = async ( + operation: () => Promise, + retries = MAX_RETRIES, +): Promise => { + for (let attempt = 0; attempt < retries; attempt++) { + try { + return await operation(); + } catch (err) { + const errorMessage = (err as Error).message || ""; + const isRetryable = + errorMessage.includes("catalog changes") || + errorMessage.includes("please retry") || + errorMessage.includes("WriteConflict") || + (err as { code?: number }).code === 112; + + if (!isRetryable || attempt === retries - 1) { + throw err; + } + + const delay = BASE_DELAY_MS * Math.pow(2, attempt); + await sleep(delay); + } + } + throw new Error("Max retries exceeded"); +}; + +export const clearDB = async () => { + await retryOperation(async () => { + await mongoose.connection.dropDatabase(); + }); + + await mongoose.connection.syncIndexes(); +}; diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..6f14cc5 --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,9 @@ +import { IUser } from "@modules/user/user.types"; + +declare global { + namespace Express { + interface Request { + user?: IUser; + } + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 4ca1aa2..46c3180 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,7 @@ export type UNIT_OF_TIME = export interface IErrorPayload { success: boolean; error: string; + details?: string; } export interface ISuccessPayload { success: boolean; diff --git a/src/utils/encryptors.ts b/src/utils/encryptors.ts index 6f603a4..3f88251 100644 --- a/src/utils/encryptors.ts +++ b/src/utils/encryptors.ts @@ -9,3 +9,6 @@ export async function hashWithBcrypt(str: string, salt?: string) { if (!salt) salt = await bcrypt.genSalt(10); return await bcrypt.hash(str, salt); } +export async function compareHashedBcryptString(plain: string, hashed: string) { + return await bcrypt.compare(plain, hashed); +} diff --git a/src/utils/generators.ts b/src/utils/generators.ts index d155f58..7e4ed65 100644 --- a/src/utils/generators.ts +++ b/src/utils/generators.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import crypto from "crypto"; export function generateCode(numberOfDigits: number) { const isNumberOfDigitsGeneratable = z @@ -16,3 +17,7 @@ export function generateCode(numberOfDigits: number) { Math.random() * 9 * Math.pow(10, numberOfDigits - 1), ).toString(); } + +export function generateRandomTokenWithCrypto(bytes = 64) { + return crypto.randomBytes(bytes).toString("hex"); +} From 27effb71d421d962842a673a148f10e8306cde98 Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Sat, 27 Dec 2025 20:31:45 +0100 Subject: [PATCH 40/48] feat: create tests and logout route --- .../__tests__/integration/logout.v1.test.ts | 131 ++++++++++++++++++ src/modules/auth/auth.controller.ts | 28 +++- src/modules/auth/auth.docs.ts | 11 ++ src/modules/auth/auth.service.ts | 23 ++- src/modules/auth/auth.types.ts | 4 + src/modules/auth/routes/auth.v1.routes.ts | 2 + src/tests/utils.ts | 40 ++++++ 7 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/modules/auth/__tests__/integration/logout.v1.test.ts diff --git a/src/modules/auth/__tests__/integration/logout.v1.test.ts b/src/modules/auth/__tests__/integration/logout.v1.test.ts new file mode 100644 index 0000000..8a840f6 --- /dev/null +++ b/src/modules/auth/__tests__/integration/logout.v1.test.ts @@ -0,0 +1,131 @@ +import request from "supertest"; +import app from "@app"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { + clearDB, + extractSignedCookies, + extractSignedCookie, +} from "@tests/utils"; +import { RefreshTokenModel } from "@modules/auth/refreshToken.model"; +import UserModel from "@modules/user/user.model"; + +const verifiedUserEmail = "verified@example.com"; +const testPassword = "secret123"; + +beforeEach(async () => { + await clearDB(); +}); + +beforeEach(async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); +}); + +describe("Auth Logout", () => { + it("clears access and refresh cookies on logout", async () => { + const loginRes = await request(app) + .post("/api/v1/auth/login") + .send({ email: verifiedUserEmail, password: testPassword }); + + expect(loginRes.status).toBe(200); + + const loginCookies = extractSignedCookies(loginRes.headers["set-cookie"], [ + "access_token", + "refresh_token", + ]); + + if (!loginCookies.access_token || !loginCookies.refresh_token) { + throw new Error("Login cookies not found"); + } + + const res = await request(app) + .post("/api/v1/auth/logout") + .set("Cookie", [ + `access_token=${loginCookies.access_token}`, + `refresh_token=${loginCookies.refresh_token}`, + ]); + + const cookies = res.headers["set-cookie"]; + if (!cookies) { + throw new Error("No set-cookie header found"); + } + + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + const cookieString = cookieArray.join(" "); + + expect(cookieString).toMatch(/access_token=.*(Max-Age=0|Expires=)/); + expect(cookieString).toMatch(/refresh_token=.*(Max-Age=0|Expires=)/); + }); + it("blocks protected routes after logout", async () => { + const loginRes = await request(app) + .post("/api/v1/auth/login") + .send({ email: verifiedUserEmail, password: testPassword }); + + expect(loginRes.status).toBe(200); + + const loginCookies = extractSignedCookies(loginRes.headers["set-cookie"], [ + "access_token", + "refresh_token", + ]); + + if (!loginCookies.access_token || !loginCookies.refresh_token) { + throw new Error("Login cookies not found"); + } + + await request(app) + .post("/api/v1/auth/logout") + .set("Cookie", [ + `access_token=${loginCookies.access_token}`, + `refresh_token=${loginCookies.refresh_token}`, + ]); + + const res = await request(app).get("/api/v1/auth/me"); + expect(res.status).toBe(401); + }); + it("logout is idempotent", async () => { + const firstRes = await request(app).post("/api/v1/auth/logout"); + expect(firstRes.status).toBe(200); + + const res = await request(app).post("/api/v1/auth/logout"); + expect(res.status).toBe(200); + }); + it("logout works without authentication", async () => { + const res = await request(app).post("/api/v1/auth/logout"); + expect(res.status).toBe(200); + }); + it("revokes refresh token in database", async () => { + const loginRes = await request(app) + .post("/api/v1/auth/login") + .send({ email: verifiedUserEmail, password: testPassword }); + + expect(loginRes.status).toBe(200); + + const refreshTokenValue = extractSignedCookie( + loginRes.headers["set-cookie"], + "refresh_token", + ); + if (!refreshTokenValue) throw new Error("Refresh token cookie not found"); + + const userDoc = await UserModel.findOne({ email: verifiedUserEmail }); + if (!userDoc) throw new Error("User not found"); + + const tokenDoc = await RefreshTokenModel.findOne({ + user: userDoc._id, + revokedAt: null, + }); + + if (!tokenDoc) throw new Error("Refresh token not found"); + + const logoutRes = await request(app) + .post("/api/v1/auth/logout") + .set("Cookie", [`refresh_token=${refreshTokenValue}`]); + + expect(logoutRes.status).toBe(200); + + const revoked = await RefreshTokenModel.findById(tokenDoc._id); + expect(revoked?.revokedAt).not.toBeNull(); + }); +}); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 17674bd..a33ed5b 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { EmailVerificationOutput, GetMeOutput, LoginOutput, + LogoutOutput, SendEmailVerificationCodeOutput, SignupInput, SignupOutput, @@ -14,7 +15,7 @@ import UserService from "@modules/user/user.service"; import { routeTryCatcher } from "@utils/routeTryCatcher"; import { compareHashedBcryptString } from "@utils/encryptors"; import { serializeUser } from "@modules/user/user.utils"; -import { setAuthCookies } from "./utils/auth.cookies"; +import { setAuthCookies, clearAuthCookies } from "./utils/auth.cookies"; export const signupOrganizationOwner = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { @@ -146,3 +147,28 @@ export const getCurrentUser = routeTryCatcher( } as ISuccessPayload); }, ); + +export const logoutUser = routeTryCatcher( + async (req: Request, res: Response) => { + const token = req.signedCookies?.refresh_token; + clearAuthCookies(res); + if (!token) { + return res.json({ + success: true, + data: { message: "Logged out successfully" }, + } as ISuccessPayload); + } + + const result = await AuthService.logout(token, req.ip); + if (!result.success) { + return res.json({ + success: true, + data: { message: "Logged out successfully" }, + } as ISuccessPayload); + } + return res.json({ + success: true, + data: (result as ISuccessPayload).data, + } as ISuccessPayload); + }, +); diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index fe6c057..cecd9f7 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -5,6 +5,7 @@ import { GetMeOutput, loginInput, LoginOutput, + LogoutOutput, resendEmailVerificationCodeInput, SignupInput, } from "./auth.types"; @@ -75,5 +76,15 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/logout": { + post: { + summary: "Logout user"; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; }; }>; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index a50fdce..3d8a8a0 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -14,7 +14,11 @@ import { hashWithCrypto } from "@utils/encryptors"; import { RefreshTokenModel } from "./refreshToken.model"; import { DEFAULT_REFRESH_DAYS } from "@config/constants"; import { generateRandomTokenWithCrypto } from "@utils/generators"; -import { generateAccessToken, rotateRefreshToken } from "./utils/auth.tokens"; +import { + generateAccessToken, + rotateRefreshToken, + revokeRefreshToken, +} from "./utils/auth.tokens"; const AuthService = { signupOwner: async ( @@ -220,6 +224,23 @@ const AuthService = { }; } }, + logout: async ( + rawRefreshToken: string | null, + ip?: string, + ): Promise | IErrorPayload> => { + try { + await revokeRefreshToken(rawRefreshToken, ip); + return { + success: true, + data: { message: "Logged out successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as unknown as Error).message, + }; + } + }, }; export default AuthService; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index a3436ad..11e038b 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -70,3 +70,7 @@ export type GetMeOutput = { updatedAt: Date; }; }; + +export type LogoutOutput = { + message: string; +}; diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts index 0753dbe..3f0d311 100644 --- a/src/modules/auth/routes/auth.v1.routes.ts +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -14,6 +14,7 @@ import { loginUser, refreshToken, getCurrentUser, + logoutUser, } from "../auth.controller"; const authRouter = Router(); @@ -36,5 +37,6 @@ authRouter.post( authRouter.post("/login", validateResource(loginSchema), loginUser); authRouter.get("/refresh", refreshToken); authRouter.get("/me", authenticate, getCurrentUser); +authRouter.post("/logout", logoutUser); export default authRouter; diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 6ea3bf3..d125bc8 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -1,4 +1,5 @@ import mongoose from "mongoose"; +import * as cookie from "cookie"; const MAX_RETRIES = 8; const BASE_DELAY_MS = 50; @@ -38,3 +39,42 @@ export const clearDB = async () => { await mongoose.connection.syncIndexes(); }; + +export const extractSignedCookie = ( + setCookieHeader: string | string[] | undefined, + cookieName: string, +): string | null => { + if (!setCookieHeader) return null; + + const cookieArray = Array.isArray(setCookieHeader) + ? setCookieHeader + : [setCookieHeader]; + + const cookieString = cookieArray.find((c) => c.startsWith(`${cookieName}=`)); + if (!cookieString) return null; + + const parsed = cookie.parse(cookieString); + const signedValue = parsed[cookieName]; + + if (!signedValue || !signedValue.startsWith("s:")) { + return null; + } + + return signedValue; +}; + +export const extractSignedCookies = ( + setCookieHeader: string | string[] | undefined, + cookieNames: string[], +): Record => { + const result: Record = {}; + + for (const cookieName of cookieNames) { + const value = extractSignedCookie(setCookieHeader, cookieName); + if (value) { + result[cookieName] = value; + } + } + + return result; +}; From 01dcce312e2715dc6247766e6fa42590ec924b0a Mon Sep 17 00:00:00 2001 From: Exploit Date: Sun, 28 Dec 2025 13:58:10 +0100 Subject: [PATCH 41/48] Feature/Change-Password (#9) - Create shared test helpers for auth tests (testHelpers.ts) - Extract common test constants (verifiedUserEmail, testPassword, etc.) - Extract createSignedAccessTokenCookie function to shared helpers - Update all auth integration tests to use shared helpers - Add reusable passwordSchema with conventional password rules: - Minimum 8 characters - At least one uppercase letter - At least one lowercase letter - At least one number - At least one special character - Update signupSchema and changePasswordSchema to use passwordSchema --- .../auth/__tests__/helpers/testHelpers.ts | 14 + .../integration/changePassword.v1.test.ts | 282 ++++++++++++++++++ .../__tests__/integration/login.v1.test.ts | 6 +- .../__tests__/integration/logout.v1.test.ts | 4 +- .../auth/__tests__/integration/me.v1.test.ts | 15 +- .../integration/refreshToken.v1.test.ts | 4 +- src/modules/auth/auth.controller.ts | 27 ++ src/modules/auth/auth.docs.ts | 13 + src/modules/auth/auth.service.ts | 32 ++ src/modules/auth/auth.types.ts | 7 + src/modules/auth/auth.validators.ts | 23 +- src/modules/auth/routes/auth.v1.routes.ts | 8 + 12 files changed, 418 insertions(+), 17 deletions(-) create mode 100644 src/modules/auth/__tests__/helpers/testHelpers.ts create mode 100644 src/modules/auth/__tests__/integration/changePassword.v1.test.ts diff --git a/src/modules/auth/__tests__/helpers/testHelpers.ts b/src/modules/auth/__tests__/helpers/testHelpers.ts new file mode 100644 index 0000000..f554639 --- /dev/null +++ b/src/modules/auth/__tests__/helpers/testHelpers.ts @@ -0,0 +1,14 @@ +import * as signature from "cookie-signature"; +import { COOKIE_SECRET } from "@config/env"; + +export const TEST_CONSTANTS = { + verifiedUserEmail: "verified@example.com", + nonVerifiedUserEmail: "nonverified@example.com", + testPassword: "Secret123!", + newPassword: "newPassword456!", +} as const; + +export const createSignedAccessTokenCookie = (accessToken: string): string => { + const signedToken = "s:" + signature.sign(accessToken, COOKIE_SECRET); + return `access_token=${signedToken}`; +}; diff --git a/src/modules/auth/__tests__/integration/changePassword.v1.test.ts b/src/modules/auth/__tests__/integration/changePassword.v1.test.ts new file mode 100644 index 0000000..b818219 --- /dev/null +++ b/src/modules/auth/__tests__/integration/changePassword.v1.test.ts @@ -0,0 +1,282 @@ +import request from "supertest"; +import app from "@app"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { clearDB } from "@tests/utils"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserModel from "@modules/user/user.model"; +import { compareHashedBcryptString } from "@utils/encryptors"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../helpers/testHelpers"; + +const { verifiedUserEmail, testPassword, newPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +beforeEach(async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); +}); + +describe("POST /api/v1/auth/change-password", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).post("/api/v1/auth/change-password").send({ + currentPassword: testPassword, + newPassword: newPassword, + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Authentication required"); + }); + + it("should return 401 if access token is invalid", async () => { + const res = await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", ["access_token=invalid_token"]) + .send({ + currentPassword: testPassword, + newPassword: newPassword, + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if currentPassword is missing", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if newPassword is missing", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + currentPassword: testPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if newPassword is less than 6 characters", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + currentPassword: testPassword, + newPassword: "12345", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if newPassword is the same as currentPassword", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + currentPassword: testPassword, + newPassword: testPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if current password is incorrect", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + currentPassword: "wrongPassword", + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Current password is incorrect"); + }); + + it("should successfully change password with valid credentials", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + currentPassword: testPassword, + newPassword: newPassword, + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.message).toBe("Password changed successfully"); + }); + + it("should update password in database after successful change", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + currentPassword: testPassword, + newPassword: newPassword, + }); + + expect(res.status).toBe(200); + + const updatedUser = await UserModel.findById(user._id); + if (!updatedUser) throw new Error("User not found"); + + const isNewPasswordValid = await compareHashedBcryptString( + newPassword, + updatedUser.password, + ); + expect(isNewPasswordValid).toBe(true); + + const isOldPasswordInvalid = await compareHashedBcryptString( + testPassword, + updatedUser.password, + ); + expect(isOldPasswordInvalid).toBe(false); + }); + + it("should allow login with new password after change", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + currentPassword: testPassword, + newPassword: newPassword, + }); + + const loginRes = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: newPassword, + }); + + expect(loginRes.status).toBe(200); + expect(loginRes.body.success).toBe(true); + }); + + it("should not allow login with old password after change", async () => { + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (!user) throw new Error("User not found"); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + await request(app) + .post("/api/v1/auth/change-password") + .set("Cookie", [cookie]) + .send({ + currentPassword: testPassword, + newPassword: newPassword, + }); + + const loginRes = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: testPassword, + }); + + expect(loginRes.status).toBe(400); + expect(loginRes.body.success).toBe(false); + }); +}); diff --git a/src/modules/auth/__tests__/integration/login.v1.test.ts b/src/modules/auth/__tests__/integration/login.v1.test.ts index e9f6959..ea27632 100644 --- a/src/modules/auth/__tests__/integration/login.v1.test.ts +++ b/src/modules/auth/__tests__/integration/login.v1.test.ts @@ -2,10 +2,10 @@ import request from "supertest"; import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; +import { TEST_CONSTANTS } from "../helpers/testHelpers"; -const verifiedUserEmail = "verified@example.com"; -const nonVerifiedUserEmail = "nonverified@example.com"; -const testPassword = "secret123"; +const { verifiedUserEmail, nonVerifiedUserEmail, testPassword } = + TEST_CONSTANTS; beforeEach(async () => { await clearDB(); diff --git a/src/modules/auth/__tests__/integration/logout.v1.test.ts b/src/modules/auth/__tests__/integration/logout.v1.test.ts index 8a840f6..d2c2b75 100644 --- a/src/modules/auth/__tests__/integration/logout.v1.test.ts +++ b/src/modules/auth/__tests__/integration/logout.v1.test.ts @@ -8,9 +8,9 @@ import { } from "@tests/utils"; import { RefreshTokenModel } from "@modules/auth/refreshToken.model"; import UserModel from "@modules/user/user.model"; +import { TEST_CONSTANTS } from "../helpers/testHelpers"; -const verifiedUserEmail = "verified@example.com"; -const testPassword = "secret123"; +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; beforeEach(async () => { await clearDB(); diff --git a/src/modules/auth/__tests__/integration/me.v1.test.ts b/src/modules/auth/__tests__/integration/me.v1.test.ts index 289c7f8..9e520a8 100644 --- a/src/modules/auth/__tests__/integration/me.v1.test.ts +++ b/src/modules/auth/__tests__/integration/me.v1.test.ts @@ -4,12 +4,14 @@ import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; import UserModel from "@modules/user/user.model"; -import * as signature from "cookie-signature"; -import { COOKIE_SECRET } from "@config/env"; import * as jwt from "jsonwebtoken"; +import * as signature from "cookie-signature"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../helpers/testHelpers"; -const verifiedUserEmail = "verified@example.com"; -const testPassword = "secret123"; +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; beforeEach(async () => { await clearDB(); @@ -23,11 +25,6 @@ beforeEach(async () => { }); }); -const createSignedAccessTokenCookie = (accessToken: string): string => { - const signedToken = "s:" + signature.sign(accessToken, COOKIE_SECRET); - return `access_token=${signedToken}`; -}; - describe("GET /api/v1/auth/me", () => { it("should return 401 if access token cookie is missing", async () => { const res = await request(app).get("/api/v1/auth/me"); diff --git a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts index a21ef9e..55ce079 100644 --- a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts +++ b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts @@ -10,9 +10,9 @@ import UserModel from "@modules/user/user.model"; import * as cookie from "cookie"; import * as signature from "cookie-signature"; import { COOKIE_SECRET } from "@config/env"; +import { TEST_CONSTANTS } from "../helpers/testHelpers"; -const verifiedUserEmail = "verified@example.com"; -const testPassword = "secret123"; +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; beforeEach(async () => { await clearDB(); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index a33ed5b..82b6852 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import AuthService from "./auth.service"; import { + ChangePasswordOutput, EmailVerificationOutput, GetMeOutput, LoginOutput, @@ -172,3 +173,29 @@ export const logoutUser = routeTryCatcher( } as ISuccessPayload); }, ); + +export const changePassword = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user; + + if (!user) return next(AppError.unauthorized("User not found")); + + const result = await AuthService.changePassword( + user, + req.body.currentPassword, + req.body.newPassword, + ); + + if (!result.success) + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Password change failed", + ), + ); + + return res.json({ + success: true, + data: (result as ISuccessPayload).data, + } as ISuccessPayload); + }, +); diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index cecd9f7..b634811 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -1,5 +1,7 @@ import { Tspec } from "tspec"; import { + ChangePasswordInput, + ChangePasswordOutput, EmailVerificationInput, EmailVerificationOutput, GetMeOutput, @@ -86,5 +88,16 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/change-password": { + post: { + summary: "Change user password"; + body: ChangePasswordInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload & { details?: string }; + 401: IErrorPayload & { details?: string }; + }; + }; + }; }; }>; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 3d8a8a0..effa6bf 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -241,6 +241,38 @@ const AuthService = { }; } }, + changePassword: async ( + user: IUser, + currentPassword: string, + newPassword: string, + ): Promise | IErrorPayload> => { + try { + const { compareHashedBcryptString } = await import("@utils/encryptors"); + const isValidPassword = await compareHashedBcryptString( + currentPassword, + user.password, + ); + + if (!isValidPassword) + return { + success: false, + error: "Current password is incorrect", + }; + + user.password = newPassword; + await user.save(); + + return { + success: true, + data: { message: "Password changed successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as unknown as Error).message, + }; + } + }, }; export default AuthService; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 11e038b..d6ab1ef 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -1,5 +1,6 @@ import mongoose from "mongoose"; import { + changePasswordSchema, loginSchema, resendEmailVerificationCodeSchema, signupSchema, @@ -74,3 +75,9 @@ export type GetMeOutput = { export type LogoutOutput = { message: string; }; + +export type ChangePasswordInput = z.infer; + +export type ChangePasswordOutput = { + message: string; +}; diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts index 0b5e70c..c109e64 100644 --- a/src/modules/auth/auth.validators.ts +++ b/src/modules/auth/auth.validators.ts @@ -1,11 +1,22 @@ import { z } from "zod"; +export const passwordSchema = z + .string() + .min(8, "Password must be at least 8 characters long") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number") + .regex( + /[^A-Za-z0-9]/, + "Password must contain at least one special character", + ); + export const signupSchema = z .object({ firstName: z.string().min(2, "Name must be at least 2 characters long"), lastName: z.string().min(2, "Name must be at least 2 characters long"), email: z.string().email("Invalid email format"), - password: z.string().min(6, "Password must be at least 6 characters"), + password: passwordSchema, createOrg: z.boolean().optional().default(false), organizationName: z .string() @@ -47,3 +58,13 @@ export const loginSchema = z.object({ email: z.string().email("Invalid email format"), password: z.string(), }); + +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: passwordSchema, + }) + .refine((data) => data.currentPassword !== data.newPassword, { + message: "New password must be different from current password", + path: ["newPassword"], + }); diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts index 3f0d311..c994a09 100644 --- a/src/modules/auth/routes/auth.v1.routes.ts +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import validateResource from "@middlewares/validators"; import authenticate from "@middlewares/authenticate"; import { + changePasswordSchema, loginSchema, resendEmailVerificationCodeSchema, signupSchema, @@ -15,6 +16,7 @@ import { refreshToken, getCurrentUser, logoutUser, + changePassword, } from "../auth.controller"; const authRouter = Router(); @@ -38,5 +40,11 @@ authRouter.post("/login", validateResource(loginSchema), loginUser); authRouter.get("/refresh", refreshToken); authRouter.get("/me", authenticate, getCurrentUser); authRouter.post("/logout", logoutUser); +authRouter.post( + "/change-password", + authenticate, + validateResource(changePasswordSchema), + changePassword, +); export default authRouter; From 1f956238ae8531e546f6765df81b416b7ec971df Mon Sep 17 00:00:00 2001 From: Exploit Date: Sun, 28 Dec 2025 15:35:58 +0100 Subject: [PATCH 42/48] feat: implement forgot password and reset password features (#11) - Add forgot password endpoint (POST /api/v1/auth/forgot-password) - Add reset password endpoint (POST /api/v1/auth/reset-password) - Extract email template keys to environment variables - Create reusable emailSchema for DRY validation - Add password reset fields and methods to user model - Refactor code generation to use shared helper function - Add comprehensive integration tests for forgot/reset password - Add unit tests for password reset code logic - Fix verify email unit test to properly test clearing - Update user model pre-save hooks to handle null values correctly --- .env.example | 2 + src/config/env.ts | 6 + .../integration/forgotPassword.v1.test.ts | 220 ++++++++++ .../integration/resetPassword.v1.test.ts | 382 ++++++++++++++++++ .../__tests__/unit/resetPassword.unit.test.ts | 72 ++++ .../__tests__/unit/verifyEmail.unit.test.ts | 30 +- src/modules/auth/auth.controller.ts | 38 ++ src/modules/auth/auth.docs.ts | 24 ++ src/modules/auth/auth.service.ts | 97 ++++- src/modules/auth/auth.types.ts | 15 + src/modules/auth/auth.validators.ts | 22 +- src/modules/auth/routes/auth.v1.routes.ts | 14 + src/modules/user/user.model.ts | 50 ++- src/modules/user/user.types.ts | 5 + 14 files changed, 964 insertions(+), 13 deletions(-) create mode 100644 src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts create mode 100644 src/modules/auth/__tests__/integration/resetPassword.v1.test.ts create mode 100644 src/modules/auth/__tests__/unit/resetPassword.unit.test.ts diff --git a/.env.example b/.env.example index bffb36a..4060a11 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,5 @@ FROM_EMAIL=$FROM_EMAIL SUPPORT_EMAIL=$SUPPORT_EMAIL FROM_NAME=$FROM_NAME COOKIE_SECRET=$COOKIE_SECRET +EMAIL_VERIFICATION_TEMPLATE_KEY=$EMAIL_VERIFICATION_TEMPLATE_KEY +PASSWORD_RESET_TEMPLATE_KEY=$PASSWORD_RESET_TEMPLATE_KEY \ No newline at end of file diff --git a/src/config/env.ts b/src/config/env.ts index 33719df..1d7dd53 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -14,6 +14,8 @@ const envSchema = z.object({ FROM_NAME: z.string(), JWT_ACCESS_EXPIRES_IN: z.string(), COOKIE_SECRET: z.string(), + EMAIL_VERIFICATION_TEMPLATE_KEY: z.string(), + PASSWORD_RESET_TEMPLATE_KEY: z.string(), }); const isTest = process.env.NODE_ENV === "test"; @@ -33,6 +35,8 @@ const env = isTest FROM_NAME: "", JWT_ACCESS_EXPIRES_IN: "", COOKIE_SECRET: "testsecret", + EMAIL_VERIFICATION_TEMPLATE_KEY: "", + PASSWORD_RESET_TEMPLATE_KEY: "", }, error: envSchema.safeParse(process.env).error, } @@ -55,4 +59,6 @@ export const { SUPPORT_EMAIL, JWT_ACCESS_EXPIRES_IN, COOKIE_SECRET, + EMAIL_VERIFICATION_TEMPLATE_KEY, + PASSWORD_RESET_TEMPLATE_KEY, } = env.data; diff --git a/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts b/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts new file mode 100644 index 0000000..2626b8e --- /dev/null +++ b/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts @@ -0,0 +1,220 @@ +import request from "supertest"; +import app from "@app"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { clearDB } from "@tests/utils"; +import { sendEmailWithTemplate } from "@services/email.service"; +import UserModel from "@modules/user/user.model"; +import { TEST_CONSTANTS } from "../helpers/testHelpers"; + +jest.mock("@services/email.service"); + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +beforeEach(() => { + (sendEmailWithTemplate as jest.Mock).mockResolvedValue({ + success: true, + emailSent: true, + }); +}); + +function getPasswordResetCode(index = 0) { + const call = (sendEmailWithTemplate as jest.Mock).mock.calls[index][0]; + return call.merge_info.passwordResetCode; +} + +describe("POST /api/v1/auth/forgot-password", () => { + it("should return 400 if email is missing", async () => { + const res = await request(app) + .post("/api/v1/auth/forgot-password") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if email is invalid format", async () => { + const res = await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: "invalid-email" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 200 even if user does not exist (security)", async () => { + const res = await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: "nonexistent@example.com" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.emailSent).toBe(true); + expect(res.body.data.message).toBe( + "Password reset email sent successfully", + ); + }); + + it("should return 200 and send password reset email for existing user", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const res = await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.emailSent).toBe(true); + expect(res.body.data.message).toBe( + "Password reset email sent successfully", + ); + }); + + it("should generate and store password reset code in database", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const user = await UserModel.findOne({ email: verifiedUserEmail }); + expect(user).toBeTruthy(); + expect(user?.passwordResetCode).toBeTruthy(); + expect(user?.passwordResetCodeExpiry).toBeTruthy(); + }); + + it("should send email with correct template data", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + expect(sendEmailWithTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + to: [ + { + email_address: { + address: verifiedUserEmail, + name: expect.any(String), + }, + }, + ], + merge_info: { + passwordResetCode: expect.any(String), + passwordResetExpiry: "30 minutes", + name: expect.any(String), + }, + subject: "Reset your password", + }), + ); + }); + + it("should generate different codes on multiple requests", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const firstCode = getPasswordResetCode(); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const secondCode = getPasswordResetCode(1); + + expect(firstCode).not.toBe(secondCode); + }); + + it("should update password reset code expiry on new request", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const firstRequest = await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + expect(firstRequest.status).toBe(200); + + const userAfterFirst = await UserModel.findOne({ + email: verifiedUserEmail, + }); + const firstExpiry = userAfterFirst?.passwordResetCodeExpiry; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const userAfterSecond = await UserModel.findOne({ + email: verifiedUserEmail, + }); + const secondExpiry = userAfterSecond?.passwordResetCodeExpiry; + + expect(secondExpiry?.getTime()).toBeGreaterThan( + firstExpiry?.getTime() || 0, + ); + }); + + it("should work for unverified users", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: false, + }); + + const res = await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.emailSent).toBe(true); + }); + + it("should handle email service failure gracefully", async () => { + (sendEmailWithTemplate as jest.Mock).mockResolvedValueOnce({ + success: false, + emailSent: false, + error: "Email service unavailable", + }); + + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const res = await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); +}); diff --git a/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts b/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts new file mode 100644 index 0000000..03f545c --- /dev/null +++ b/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts @@ -0,0 +1,382 @@ +import request from "supertest"; +import app from "@app"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { clearDB } from "@tests/utils"; +import { sendEmailWithTemplate } from "@services/email.service"; +import UserModel from "@modules/user/user.model"; +import { convertTimeToMilliseconds } from "@utils/index"; +import { compareHashedBcryptString } from "@utils/encryptors"; +import { TEST_CONSTANTS } from "../helpers/testHelpers"; + +jest.mock("@services/email.service"); + +const { verifiedUserEmail, testPassword, newPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +beforeEach(() => { + (sendEmailWithTemplate as jest.Mock).mockResolvedValue({ + success: true, + emailSent: true, + }); +}); + +function getPasswordResetCode(index = 0) { + const call = (sendEmailWithTemplate as jest.Mock).mock.calls[index][0]; + return call.merge_info.passwordResetCode; +} + +describe("POST /api/v1/auth/reset-password", () => { + it("should return 400 if email is missing", async () => { + const res = await request(app).post("/api/v1/auth/reset-password").send({ + passwordResetCode: "123456", + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if passwordResetCode is missing", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if newPassword is missing", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: "123456", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if email is invalid format", async () => { + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: "invalid-email", + passwordResetCode: "123456", + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if passwordResetCode is not 6 digits", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: "12345", + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if newPassword does not meet requirements", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: "123456", + newPassword: "weak", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if user does not exist", async () => { + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: "nonexistent@example.com", + passwordResetCode: "123456", + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Invalid or expired password reset code"); + }); + + it("should return 400 if password reset code is invalid", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: "000000", + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Invalid or expired password reset code"); + }); + + it("should return 400 if password reset code is expired", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const user = await UserModel.findOne({ email: verifiedUserEmail }); + if (user) { + user.passwordResetCodeExpiry = new Date( + Date.now() - convertTimeToMilliseconds(1, "min"), + ); + await user.save(); + } + + const resetCode = getPasswordResetCode(); + + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Invalid or expired password reset code"); + }); + + it("should successfully reset password with valid code", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const resetCode = getPasswordResetCode(); + + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.message).toBe("Password reset successfully"); + }); + + it("should update password in database after successful reset", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const resetCode = getPasswordResetCode(); + + await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + const user = await UserModel.findOne({ email: verifiedUserEmail }); + expect(user).toBeTruthy(); + + const isNewPasswordValid = await compareHashedBcryptString( + newPassword, + user!.password, + ); + expect(isNewPasswordValid).toBe(true); + + const isOldPasswordInvalid = await compareHashedBcryptString( + testPassword, + user!.password, + ); + expect(isOldPasswordInvalid).toBe(false); + }); + + it("should clear password reset data after successful reset", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const resetCode = getPasswordResetCode(); + + const resetRes = await request(app) + .post("/api/v1/auth/reset-password") + .send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + expect(resetRes.status).toBe(200); + + const user = await UserModel.findOne({ email: verifiedUserEmail }).lean(); + expect(user).toBeTruthy(); + expect(user?.passwordResetCode).toBeNull(); + expect(user?.passwordResetCodeExpiry).toBeNull(); + }); + + it("should not allow reusing the same reset code", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const resetCode = getPasswordResetCode(); + + const firstReset = await request(app) + .post("/api/v1/auth/reset-password") + .send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + expect(firstReset.status).toBe(200); + + const secondReset = await request(app) + .post("/api/v1/auth/reset-password") + .send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + expect(secondReset.status).toBe(400); + expect(secondReset.body.success).toBe(false); + }); + + it("should allow login with new password after reset", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const resetCode = getPasswordResetCode(); + + await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + const loginRes = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: newPassword, + }); + + expect(loginRes.status).toBe(200); + expect(loginRes.body.success).toBe(true); + }); + + it("should not allow login with old password after reset", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const resetCode = getPasswordResetCode(); + + await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + const loginRes = await request(app).post("/api/v1/auth/login").send({ + email: verifiedUserEmail, + password: testPassword, + }); + + expect(loginRes.status).toBe(400); + expect(loginRes.body.success).toBe(false); + }); + + it("should work for unverified users", async () => { + await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: false, + }); + + await request(app) + .post("/api/v1/auth/forgot-password") + .send({ email: verifiedUserEmail }); + + const resetCode = getPasswordResetCode(); + + const res = await request(app).post("/api/v1/auth/reset-password").send({ + email: verifiedUserEmail, + passwordResetCode: resetCode, + newPassword: newPassword, + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); +}); diff --git a/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts b/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts new file mode 100644 index 0000000..fd2d914 --- /dev/null +++ b/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts @@ -0,0 +1,72 @@ +import UserModel from "@modules/user/user.model"; +import { UserFactory } from "@tests/factories/user.factory"; +import { convertTimeToMilliseconds } from "@utils/index"; + +describe("Password Reset Code Logic", () => { + it("successfully generates a hashed password reset code", async () => { + const user = new UserModel({ + ...UserFactory.generate(), + role: "owner", + }); + const code = user.generatePasswordResetCode(); + await user.save(); + expect(code).toHaveLength(6); + expect(code).not.toBe(user.passwordResetCode); + expect(user.passwordResetCodeExpiry).toBeDefined(); + }); + + it("fails verification if code is wrong", async () => { + const user = new UserModel({ + ...UserFactory.generate(), + role: "owner", + }); + const code = user.generatePasswordResetCode(); + await user.save(); + const isCorrectCode = user.verifyPasswordResetCode( + code.split("").reverse().join(""), + ); + expect(isCorrectCode).toBe(false); + }); + + it("fails verification if code is expired", async () => { + const user = new UserModel({ + ...UserFactory.generate(), + role: "owner", + }); + const code = user.generatePasswordResetCode(); + await user.save(); + user.passwordResetCodeExpiry = new Date( + Date.now() - convertTimeToMilliseconds(60, "minutes"), + ); + await user.save(); + const isCorrectCode = user.verifyPasswordResetCode(code); + expect(isCorrectCode).toBe(false); + }); + + it("successfully verifies code with correct code", async () => { + const user = new UserModel({ + ...UserFactory.generate(), + role: "owner", + }); + const code = user.generatePasswordResetCode(); + await user.save(); + const isCorrectCode = user.verifyPasswordResetCode(code); + expect(isCorrectCode).toBe(true); + }); + + it("clears password reset data after clearing", async () => { + const user = new UserModel({ + ...UserFactory.generate(), + role: "owner", + }); + user.generatePasswordResetCode(); + await user.save(); + expect(user.passwordResetCode).toBeTruthy(); + expect(user.passwordResetCodeExpiry).toBeTruthy(); + + await user.clearPasswordResetData(); + + expect(user.passwordResetCode).toBeNull(); + expect(user.passwordResetCodeExpiry).toBeNull(); + }); +}); diff --git a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts index 00d9c41..7ed0cba 100644 --- a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts +++ b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts @@ -32,11 +32,31 @@ describe("Email Verification Code Logic", () => { expect(isCorrectCode).toBe(false); }); - it("clears verification data after code is verified", async () => { - const user = new UserModel(UserFactory.generate()); + it("successfully verifies code with correct code", async () => { + const user = new UserModel({ + ...UserFactory.generate(), + role: "owner", + }); const code = user.generateEmailVerificationCode(); - expect(code).toHaveLength(6); - expect(code).not.toBe(user.emailVerificationCode); - expect(user.emailVerificationCodeExpiry).toBeDefined(); + await user.save(); + const isCorrectCode = user.verifyEmailVerificationCode(code); + expect(isCorrectCode).toBe(true); + expect(user.isEmailVerified).toBe(true); + }); + + it("clears verification data after clearing", async () => { + const user = new UserModel({ + ...UserFactory.generate(), + role: "owner", + }); + user.generateEmailVerificationCode(); + await user.save(); + expect(user.emailVerificationCode).toBeTruthy(); + expect(user.emailVerificationCodeExpiry).toBeTruthy(); + + await user.clearEmailVerificationData(); + + expect(user.emailVerificationCode).toBeNull(); + expect(user.emailVerificationCodeExpiry).toBeNull(); }); }); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 82b6852..a22d183 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -3,9 +3,11 @@ import AuthService from "./auth.service"; import { ChangePasswordOutput, EmailVerificationOutput, + ForgotPasswordOutput, GetMeOutput, LoginOutput, LogoutOutput, + ResetPasswordOutput, SendEmailVerificationCodeOutput, SignupInput, SignupOutput, @@ -199,3 +201,39 @@ export const changePassword = routeTryCatcher( } as ISuccessPayload); }, ); + +export const forgotPassword = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const result = await AuthService.sendPasswordResetEmail(req.body.email); + if (!result.success) + return next( + AppError.badRequest( + (result as IErrorPayload).error || + "Failed to send password reset email", + ), + ); + return res + .status(200) + .json(result as ISuccessPayload); + }, +); + +export const resetPassword = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const result = await AuthService.resetPassword( + req.body.email, + req.body.passwordResetCode, + req.body.newPassword, + ); + if (!result.success) + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Password reset failed", + ), + ); + return res.json({ + success: true, + data: (result as ISuccessPayload).data, + } as ISuccessPayload); + }, +); diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index b634811..0ed43cf 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -4,11 +4,15 @@ import { ChangePasswordOutput, EmailVerificationInput, EmailVerificationOutput, + ForgotPasswordInput, + ForgotPasswordOutput, GetMeOutput, loginInput, LoginOutput, LogoutOutput, resendEmailVerificationCodeInput, + ResetPasswordInput, + ResetPasswordOutput, SignupInput, } from "./auth.types"; import { ISuccessPayload, IErrorPayload } from "src/types"; @@ -99,5 +103,25 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ }; }; }; + "/forgot-password": { + post: { + summary: "Send password reset email"; + body: ForgotPasswordInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + }; + }; + }; + "/reset-password": { + post: { + summary: "Reset password with reset code"; + body: ResetPasswordInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + }; + }; + }; }; }>; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index effa6bf..8baf879 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,6 +1,8 @@ import UserModel from "@modules/user/user.model"; import { EmailVerificationOutput, + ForgotPasswordOutput, + ResetPasswordOutput, SendEmailVerificationCodeOutput, SignupInput, SignupOutput, @@ -14,6 +16,10 @@ import { hashWithCrypto } from "@utils/encryptors"; import { RefreshTokenModel } from "./refreshToken.model"; import { DEFAULT_REFRESH_DAYS } from "@config/constants"; import { generateRandomTokenWithCrypto } from "@utils/generators"; +import { + EMAIL_VERIFICATION_TEMPLATE_KEY, + PASSWORD_RESET_TEMPLATE_KEY, +} from "@config/env"; import { generateAccessToken, rotateRefreshToken, @@ -108,8 +114,7 @@ const AuthService = { name: user.firstName, }, subject: "Verify your email", - mail_template_key: - "2d6f.45d8a1809f293f51.k1.46541e40-afd8-11f0-a465-fae9afc80e45.19a0fb93624", + mail_template_key: EMAIL_VERIFICATION_TEMPLATE_KEY, template_alias: "email-verification", }); return { @@ -273,6 +278,94 @@ const AuthService = { }; } }, + sendPasswordResetEmail: async ( + email: string, + ): Promise | IErrorPayload> => { + try { + const user = await UserModel.findOne({ email }); + if (!user) { + return { + success: true, + data: { + emailSent: true, + message: "Password reset email sent successfully", + }, + }; + } + + const code = user.generatePasswordResetCode(); + await user.save(); + + const emailSentResponse = await sendEmailWithTemplate({ + to: [ + { + email_address: { + address: user.email, + name: `${user.firstName} ${user.lastName}`, + }, + }, + ], + merge_info: { + passwordResetCode: code, + passwordResetExpiry: "30 minutes", + name: user.firstName, + }, + subject: "Reset your password", + mail_template_key: PASSWORD_RESET_TEMPLATE_KEY, + template_alias: "password-reset", + }); + + if (!emailSentResponse.success) { + return { + success: false, + error: + emailSentResponse.error || "Failed to send password reset email", + }; + } + + return { + success: true, + data: { + emailSent: emailSentResponse.emailSent || false, + message: "Password reset email sent successfully", + }, + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + resetPassword: async ( + email: string, + code: string, + newPassword: string, + ): Promise | IErrorPayload> => { + try { + const user = await UserModel.findOne({ email }); + if (!user) + return { + success: false, + error: "Invalid or expired password reset code", + }; + + const isValidCode = user.verifyPasswordResetCode(code); + if (!isValidCode) + return { + success: false, + error: "Invalid or expired password reset code", + }; + + await user.clearPasswordResetData(); + user.password = newPassword; + await user.save(); + + return { + success: true, + data: { message: "Password reset successfully" }, + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, }; export default AuthService; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index d6ab1ef..a1c13c3 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -1,8 +1,10 @@ import mongoose from "mongoose"; import { changePasswordSchema, + forgotPasswordSchema, loginSchema, resendEmailVerificationCodeSchema, + resetPasswordSchema, signupSchema, verifyEmailSchema, } from "./auth.validators"; @@ -81,3 +83,16 @@ export type ChangePasswordInput = z.infer; export type ChangePasswordOutput = { message: string; }; + +export type ForgotPasswordInput = z.infer; + +export type ForgotPasswordOutput = { + emailSent: boolean; + message: string; +}; + +export type ResetPasswordInput = z.infer; + +export type ResetPasswordOutput = { + message: string; +}; diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts index c109e64..3d4c119 100644 --- a/src/modules/auth/auth.validators.ts +++ b/src/modules/auth/auth.validators.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +export const emailSchema = z.string().email("Invalid email format"); + export const passwordSchema = z .string() .min(8, "Password must be at least 8 characters long") @@ -15,7 +17,7 @@ export const signupSchema = z .object({ firstName: z.string().min(2, "Name must be at least 2 characters long"), lastName: z.string().min(2, "Name must be at least 2 characters long"), - email: z.string().email("Invalid email format"), + email: emailSchema, password: passwordSchema, createOrg: z.boolean().optional().default(false), organizationName: z @@ -47,15 +49,15 @@ export const verifyEmailSchema = z.object({ emailVerificationCode: z .string() .length(6, "Email verification code must be a 6 digit number"), - email: z.string().email(), + email: emailSchema, }); export const resendEmailVerificationCodeSchema = z.object({ - email: z.string().email(), + email: emailSchema, }); export const loginSchema = z.object({ - email: z.string().email("Invalid email format"), + email: emailSchema, password: z.string(), }); @@ -68,3 +70,15 @@ export const changePasswordSchema = z message: "New password must be different from current password", path: ["newPassword"], }); + +export const forgotPasswordSchema = z.object({ + email: emailSchema, +}); + +export const resetPasswordSchema = z.object({ + email: emailSchema, + passwordResetCode: z + .string() + .length(6, "Password reset code must be a 6 digit number"), + newPassword: passwordSchema, +}); diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts index c994a09..ff865fa 100644 --- a/src/modules/auth/routes/auth.v1.routes.ts +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -3,8 +3,10 @@ import validateResource from "@middlewares/validators"; import authenticate from "@middlewares/authenticate"; import { changePasswordSchema, + forgotPasswordSchema, loginSchema, resendEmailVerificationCodeSchema, + resetPasswordSchema, signupSchema, verifyEmailSchema, } from "../auth.validators"; @@ -17,6 +19,8 @@ import { getCurrentUser, logoutUser, changePassword, + forgotPassword, + resetPassword, } from "../auth.controller"; const authRouter = Router(); @@ -46,5 +50,15 @@ authRouter.post( validateResource(changePasswordSchema), changePassword, ); +authRouter.post( + "/forgot-password", + validateResource(forgotPasswordSchema), + forgotPassword, +); +authRouter.post( + "/reset-password", + validateResource(resetPasswordSchema), + resetPassword, +); export default authRouter; diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index ce7b212..f8e03f2 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -24,6 +24,8 @@ const userSchema = new Schema( isEmailVerified: { type: Boolean, default: false }, emailVerificationCode: { type: String, default: null }, emailVerificationCodeExpiry: { type: Date, default: null }, + passwordResetCode: { type: String, default: null }, + passwordResetCodeExpiry: { type: Date, default: null }, }, { timestamps: true }, ); @@ -44,14 +46,38 @@ userSchema.pre("save", async function (next) { userSchema.pre("save", async function (next) { const thisObj = this as IUser; if (!this.isModified("emailVerificationCode")) return next(); + if (thisObj.emailVerificationCode === null) { + thisObj.emailVerificationCodeExpiry = null; + return next(); + } thisObj.emailVerificationCodeExpiry = new Date( Date.now() + convertTimeToMilliseconds(30, "minutes"), ); + next(); }); -userSchema.methods.generateEmailVerificationCode = function (): string { +userSchema.pre("save", async function (next) { + const thisObj = this as IUser; + if (!this.isModified("passwordResetCode")) return next(); + if (thisObj.passwordResetCode === null) { + thisObj.passwordResetCodeExpiry = null; + return next(); + } + thisObj.passwordResetCodeExpiry = new Date( + Date.now() + convertTimeToMilliseconds(30, "minutes"), + ); + next(); +}); + +function generateAndHashSixDigitCode(): { code: string; hashedCode: string } { const code = Math.floor(100000 + Math.random() * 900000).toString(); - this.emailVerificationCode = hashWithCrypto(code); + const hashedCode = hashWithCrypto(code); + return { code, hashedCode }; +} + +userSchema.methods.generateEmailVerificationCode = function (): string { + const { code, hashedCode } = generateAndHashSixDigitCode(); + this.emailVerificationCode = hashedCode; return code; }; @@ -71,6 +97,26 @@ userSchema.methods.clearEmailVerificationData = async function () { await this.save(); }; +userSchema.methods.generatePasswordResetCode = function (): string { + const { code, hashedCode } = generateAndHashSixDigitCode(); + this.passwordResetCode = hashedCode; + return code; +}; + +userSchema.methods.verifyPasswordResetCode = function (code: string): boolean { + const isCorrectCode = hashWithCrypto(code) === this.passwordResetCode; + const isNotExpiredCode = + this.passwordResetCodeExpiry && + new Date(this.passwordResetCodeExpiry).getTime() > Date.now(); + return isCorrectCode && !!isNotExpiredCode; +}; + +userSchema.methods.clearPasswordResetData = async function () { + this.passwordResetCode = null; + this.passwordResetCodeExpiry = null; + await this.save(); +}; + const UserModel = mongoose.model("User", userSchema); export default UserModel; diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index cdce7d1..640b429 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -16,7 +16,12 @@ export interface IUser extends mongoose.Document { isEmailVerified: boolean; emailVerificationCode?: string | null; emailVerificationCodeExpiry?: Date | null; + passwordResetCode?: string | null; + passwordResetCodeExpiry?: Date | null; generateEmailVerificationCode: () => string; verifyEmailVerificationCode: (code: string) => boolean; clearEmailVerificationData: () => Promise; + generatePasswordResetCode: () => string; + verifyPasswordResetCode: (code: string) => boolean; + clearPasswordResetData: () => Promise; } From dc4b4da977209b1930de0ab7796db7524ddf4cda Mon Sep 17 00:00:00 2001 From: Exploit Date: Mon, 29 Dec 2025 13:58:07 +0100 Subject: [PATCH 43/48] Simplify signup to remove organization creation (#13) - Remove organization creation logic from signup flow - Simplify signup schema to only require user fields - Rename signupOwner to signup for clarity - Remove organization-related fields from signup input/output - Update tests to remove organization-related test cases - Clean up verifyEmail tests to remove unused organization fields - Update API documentation to reflect simplified signup --- .../__tests__/integration/signup.v1.test.ts | 67 ++----------------- .../integration/verifyEmail.v1.test.ts | 45 ++----------- src/modules/auth/auth.controller.ts | 4 +- src/modules/auth/auth.docs.ts | 2 +- src/modules/auth/auth.service.ts | 55 +++------------ src/modules/auth/auth.types.ts | 1 - src/modules/auth/auth.validators.ts | 37 ++-------- src/modules/auth/routes/auth.v1.routes.ts | 8 +-- 8 files changed, 32 insertions(+), 187 deletions(-) diff --git a/src/modules/auth/__tests__/integration/signup.v1.test.ts b/src/modules/auth/__tests__/integration/signup.v1.test.ts index 7382059..1c2c2f3 100644 --- a/src/modules/auth/__tests__/integration/signup.v1.test.ts +++ b/src/modules/auth/__tests__/integration/signup.v1.test.ts @@ -1,9 +1,7 @@ import request from "supertest"; import app from "@app"; -import mongoose from "mongoose"; import { userFixtures } from "@tests/fixtures/user"; import { UserFactory } from "@tests/factories/user.factory"; -import { OrganizationFactory } from "@tests/factories/organization.factory"; import { clearDB } from "@tests/utils"; beforeEach(async () => { @@ -11,120 +9,67 @@ beforeEach(async () => { }); describe("Auth Signup", () => { - it("should return 400 if organization name is missing when createOrg is true", async () => { - const res = await request(app) - .post("/api/v1/auth/signup") - .send({ - ...UserFactory.generate(), - createOrg: true, - organizationSize: 10, - }); - - expect(res.status).toBe(400); - }); it("should return 400 if email is missing", async () => { const res = await request(app) .post("/api/v1/auth/signup") .send({ ...userFixtures.noEmail, - ...OrganizationFactory.generate(), }); expect(res.status).toBe(400); }); + it("should return 400 if password is missing", async () => { const res = await request(app) .post("/api/v1/auth/signup") .send({ ...userFixtures.noPassword, - ...OrganizationFactory.generate(), }); expect(res.status).toBe(400); }); + it("should return 400 if first name is missing", async () => { const res = await request(app) .post("/api/v1/auth/signup") .send({ ...userFixtures.noFirstName, - ...OrganizationFactory.generate(), }); expect(res.status).toBe(400); }); + it("should return 400 if last name is missing", async () => { const res = await request(app) .post("/api/v1/auth/signup") .send({ ...userFixtures.noLastName, - ...OrganizationFactory.generate(), }); expect(res.status).toBe(400); }); + it("should return 400 if email is invalid", async () => { const res = await request(app) .post("/api/v1/auth/signup") .send({ ...userFixtures.invalidEmail, - ...OrganizationFactory.generate(), - }); - - expect(res.status).toBe(400); - }); - it("should return 400 if organization size is missing when createOrg is true", async () => { - const orgData = OrganizationFactory.generate(); - const res = await request(app) - .post("/api/v1/auth/signup") - .send({ - ...UserFactory.generate(), - createOrg: true, - organizationName: orgData.name, }); expect(res.status).toBe(400); }); - it("should return 201 when a user signs up successfully with organization", async () => { - const orgData = OrganizationFactory.generate(); - const res = await request(app) - .post("/api/v1/auth/signup") - .send({ - ...UserFactory.generate(), - createOrg: true, - organizationName: orgData.name, - organizationSize: orgData.size, - }); - - expect(res.status).toBe(201); - expect(res.body).toHaveProperty("data"); - expect(res.body.data).toHaveProperty("userId"); - expect(res.body.data).toHaveProperty("organizationId"); - }); - - it("should return 201 when a user signs up successfully without organization", async () => { - const res = await request(app) - .post("/api/v1/auth/signup") - .send({ - ...UserFactory.generate(), - }); - - expect(res.status).toBe(201); - expect(res.body).toHaveProperty("data"); - expect(res.body.data).toHaveProperty("userId"); - expect(res.body.data).not.toHaveProperty("organizationId"); - }); - it("should return 201 when a user signs up with createOrg false", async () => { + it("should return 201 when a user signs up successfully", async () => { const res = await request(app) .post("/api/v1/auth/signup") .send({ ...UserFactory.generate(), - createOrg: false, }); expect(res.status).toBe(201); expect(res.body).toHaveProperty("data"); expect(res.body.data).toHaveProperty("userId"); + expect(res.body.data).toHaveProperty("emailSent"); expect(res.body.data).not.toHaveProperty("organizationId"); }); }); diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts index 33d0653..57f7468 100644 --- a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -1,7 +1,6 @@ import request from "supertest"; import app from "@app"; import { UserFactory } from "@tests/factories/user.factory"; -import { OrganizationFactory } from "@tests/factories/organization.factory"; import { sendEmailWithTemplate } from "@services/email.service"; import UserModel from "@modules/user/user.model"; import { convertTimeToMilliseconds } from "@utils/index"; @@ -27,13 +26,7 @@ function getVerificationCode(index = 0) { describe("Email Verification", () => { it("should not verify user's email with invalid code", async () => { - const orgData = OrganizationFactory.generate(); - const user = { - ...UserFactory.generate(), - createOrg: true, - organizationName: orgData.name, - organizationSize: orgData.size, - }; + const user = UserFactory.generate(); const signupResponse = await request(app) .post("/api/v1/auth/signup") @@ -48,13 +41,7 @@ describe("Email Verification", () => { }); it("should not verify user's email with expired code", async () => { - const orgData = OrganizationFactory.generate(); - const user = { - ...UserFactory.generate(), - createOrg: true, - organizationName: orgData.name, - organizationSize: orgData.size, - }; + const user = UserFactory.generate(); await request(app).post("/api/v1/auth/signup").send(user); @@ -78,13 +65,7 @@ describe("Email Verification", () => { }); it("should verify user's email after signup", async () => { - const orgData = OrganizationFactory.generate(); - const user = { - ...UserFactory.generate(), - createOrg: true, - organizationName: orgData.name, - organizationSize: orgData.size, - }; + const user = UserFactory.generate(); await request(app).post("/api/v1/auth/signup").send(user); @@ -100,13 +81,7 @@ describe("Email Verification", () => { }); it("should fail if user retries with the same code after being verified", async () => { - const orgData = OrganizationFactory.generate(); - const user = { - ...UserFactory.generate(), - createOrg: true, - organizationName: orgData.name, - organizationSize: orgData.size, - }; + const user = UserFactory.generate(); await request(app).post("/api/v1/auth/signup").send(user); @@ -131,13 +106,7 @@ describe("Email Verification", () => { expect(secondVerificationResponse.body.success).toBe(false); }); it("resends verification code and previous code is different from new code", async () => { - const orgData = OrganizationFactory.generate(); - const user = { - ...UserFactory.generate(), - createOrg: true, - organizationName: orgData.name, - organizationSize: orgData.size, - }; + const user = UserFactory.generate(); await request(app).post("/api/v1/auth/signup").send(user); @@ -151,9 +120,7 @@ describe("Email Verification", () => { expect(getVerificationCode() === getVerificationCode(1)).toBeFalsy(); }); it("cannot resend verification email to non existent user", async () => { - const user = { - ...UserFactory.generate(), - }; + const user = UserFactory.generate(); const resendVerificationCodeResponse = await request(app) .post("/api/v1/auth/resend-verification-email") diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index a22d183..d2ef5b0 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -20,11 +20,11 @@ import { compareHashedBcryptString } from "@utils/encryptors"; import { serializeUser } from "@modules/user/user.utils"; import { setAuthCookies, clearAuthCookies } from "./utils/auth.cookies"; -export const signupOrganizationOwner = routeTryCatcher( +export const signup = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { const input: SignupInput = req.body; - const result = await AuthService.signupOwner(input); + const result = await AuthService.signup(input); if ((result as IErrorPayload).error) return next( diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index 0ed43cf..4c23f98 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -24,7 +24,7 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ paths: { "/signup": { post: { - summary: "Signup an organization owner"; + summary: "Signup a new user"; body: SignupInput; responses: { 201: ISuccessPayload; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 8baf879..9df635a 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -7,8 +7,6 @@ import { SignupInput, SignupOutput, } from "./auth.types"; -import OrganizationModel from "@modules/organization/organization.model"; -import mongoose from "mongoose"; import { IUser } from "@modules/user/user.types"; import { sendEmailWithTemplate } from "@services/email.service"; import { ISuccessPayload, IErrorPayload } from "src/types"; @@ -27,63 +25,28 @@ import { } from "./utils/auth.tokens"; const AuthService = { - signupOwner: async ( + signup: async ( input: SignupInput, ): Promise | IErrorPayload> => { - const { + const { firstName, lastName, email, password } = input; + const existingUser = await UserModel.exists({ email }); + if (existingUser) return { success: false, error: "User already exists" }; + + const createdUser = new UserModel({ firstName, lastName, email, password, - createOrg = false, - organizationName, - organizationSize, - } = input; - const existingUser = await UserModel.exists({ email }); - if (existingUser) return { success: false, error: "User already exists" }; - let createdUser; - let organization; - const session = await mongoose.startSession(); - session.startTransaction(); - try { - createdUser = new UserModel({ - firstName, - lastName, - email, - password, - role: "owner", - }); - - if (createOrg) { - if (!organizationName || organizationSize === undefined) { - throw new Error("Organization name and size are required"); - } - organization = new OrganizationModel({ - name: organizationName, - owner: createdUser._id, - size: organizationSize, - }); - createdUser.organization = organization._id; - await organization.save({ session }); - } + role: "owner", + }); - await createdUser.save({ session }); - await session.commitTransaction(); - } catch (err) { - if (session.inTransaction()) { - await session.abortTransaction(); - } - throw err; - } finally { - session.endSession(); - } + await createdUser.save(); const res = await AuthService.sendVerificationEmail(createdUser); return { success: true, data: { userId: createdUser._id.toString(), - ...(organization && { organizationId: organization._id.toString() }), emailSent: res.success ? (res as ISuccessPayload).data .emailSent diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index a1c13c3..133434b 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -26,7 +26,6 @@ export interface IRefreshTokenDoc extends Document { export type SignupInput = z.infer; export type SignupOutput = { - organizationId?: string; userId: string; emailSent: boolean; }; diff --git a/src/modules/auth/auth.validators.ts b/src/modules/auth/auth.validators.ts index 3d4c119..0f9debe 100644 --- a/src/modules/auth/auth.validators.ts +++ b/src/modules/auth/auth.validators.ts @@ -13,37 +13,12 @@ export const passwordSchema = z "Password must contain at least one special character", ); -export const signupSchema = z - .object({ - firstName: z.string().min(2, "Name must be at least 2 characters long"), - lastName: z.string().min(2, "Name must be at least 2 characters long"), - email: emailSchema, - password: passwordSchema, - createOrg: z.boolean().optional().default(false), - organizationName: z - .string() - .min(2, "Organization name must be at least 2 characters") - .optional(), - organizationSize: z.number().int().min(1).optional(), - }) - .superRefine((data, ctx) => { - if (data.createOrg) { - if (!data.organizationName) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Organization name is required when createOrg is true", - path: ["organizationName"], - }); - } - if (data.organizationSize === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Organization size is required when createOrg is true", - path: ["organizationSize"], - }); - } - } - }); +export const signupSchema = z.object({ + firstName: z.string().min(2, "Name must be at least 2 characters long"), + lastName: z.string().min(2, "Name must be at least 2 characters long"), + email: emailSchema, + password: passwordSchema, +}); export const verifyEmailSchema = z.object({ emailVerificationCode: z diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts index ff865fa..06577c8 100644 --- a/src/modules/auth/routes/auth.v1.routes.ts +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -11,7 +11,7 @@ import { verifyEmailSchema, } from "../auth.validators"; import { - signupOrganizationOwner, + signup, verifyEmailVerificationCode, resendEmailVerificationCode, loginUser, @@ -25,11 +25,7 @@ import { const authRouter = Router(); -authRouter.post( - "/signup", - validateResource(signupSchema), - signupOrganizationOwner, -); +authRouter.post("/signup", validateResource(signupSchema), signup); authRouter.post( "/verify-email", validateResource(verifyEmailSchema), From ccb9eccabe0ad04a4995fb14f8cd71719ea3b78f Mon Sep 17 00:00:00 2001 From: Exploit Date: Thu, 5 Mar 2026 05:37:23 +0100 Subject: [PATCH 44/48] Feature/organization endpoints (#15) * feat(org): implement organization creation endpoint with membership module - Add POST /api/v1/org endpoint to create organizations - Create separate membership module with model, service, controller, routes, and docs - Create OWNER membership automatically when organization is created - Add comprehensive integration tests for organization creation - Fix unit tests for email verification and password reset (add save() calls for pre-save hooks) - Update organization service to use membership service - Follow existing codebase patterns and DRY principles * feat(org): add GET /org endpoint to retrieve organization with caller role - Add GET /api/v1/org endpoint that returns organization and user's role - Add getMembershipByUserAndOrg service method - Add getOrganizationWithUserRole service method - Update validateResource middleware to support query parameter validation - Remove console.log from resetPassword unit test - Add proper error handling (404 for not found, 403 for not a member) * feat(org): enforce one organization per user and simplify GET endpoint - Update POST /org to return 409 if user already has an organization - Simplify GET /org to automatically retrieve user's organization (no orgId needed) - Add getUserOrganization service method - Add AppError.conflict() for 409 status code - Update all tests to reflect one organization per user constraint - Refactor getOrganization tests to use separate describe blocks - Update API documentation * feat(org): add GET /org/members endpoint for OWNER/ADMIN - Add requireRole middleware for role-based access control - Implement getOrganizationMembers service method - Add getOrganizationMembers controller handler - Add GET /org/members route protected by OWNER/ADMIN roles - Add OrganizationMember and GetOrganizationMembersOutput types - Update API documentation - Add comprehensive integration tests (13 tests) - Update membership service to populate user data and sort by creation date * refactor * create accept invite endpoint * feat: add security middleware (helmet, rate-limit), cors config and integration tests * feat: implement time entry module, standardize ID mapping, and strengthen type safety * feat: add Phase 4 - Docker, Render config, and structured logging with pino - Add Dockerfile (multi-stage build) and docker-compose.yml (API + MongoDB) - Add .dockerignore and render.yaml (Render IaC blueprint) - Replace console.log/error with pino structured logging - Replace morgan with pino-http for HTTP request logging - Add server-side error logging in errorHandler - Remove debug console.log in membership.service - Uninstall morgan, install pino/pino-http/pino-pretty * ci: optimize CI to run affected tests on PRs and full suite on push --- .dockerignore | 13 + .github/workflows/node.js.yml | 22 +- Dockerfile | 32 + docker-compose.yml | 21 + implementation_plan.md | 52 ++ jest.config.mjs | 1 + package-lock.json | 439 ++++++++-- package.json | 9 +- prioritized_checklist.md | 40 + render.yaml | 38 + src/__tests__/integration/cors.v1.test.ts | 23 + src/__tests__/integration/health.v1.test.ts | 11 + .../integration/rateLimit.v1.test.ts | 15 + src/__tests__/integration/security.v1.test.ts | 17 + src/app.ts | 35 +- src/config/db.ts | 5 +- src/config/env.ts | 6 + src/config/logger.ts | 28 + src/constants/index.ts | 37 + src/docs/health.docs.ts | 16 + src/middlewares/authenticate.ts | 1 + src/middlewares/errorHandler.ts | 7 +- src/middlewares/requireRole.ts | 43 + src/middlewares/validators.ts | 6 +- .../integration/changePassword.v1.test.ts | 22 +- .../integration/forgotPassword.v1.test.ts | 14 +- .../__tests__/integration/logout.v1.test.ts | 4 +- .../auth/__tests__/integration/me.v1.test.ts | 11 +- .../integration/refreshToken.v1.test.ts | 6 +- .../integration/resetPassword.v1.test.ts | 8 +- .../integration/verifyEmail.v1.test.ts | 4 +- .../__tests__/unit/resetPassword.unit.test.ts | 19 +- .../__tests__/unit/verifyEmail.unit.test.ts | 14 +- src/modules/auth/auth.service.ts | 28 +- src/modules/auth/utils/auth.tokens.ts | 4 +- .../__tests__/integration/client.v1.test.ts | 207 +++++ src/modules/client/client.controller.ts | 105 +++ src/modules/client/client.docs.ts | 59 ++ src/modules/client/client.model.ts | 45 ++ src/modules/client/client.service.ts | 125 +++ src/modules/client/client.types.ts | 44 + src/modules/client/client.validators.ts | 21 + src/modules/client/routes/client.v1.routes.ts | 41 + .../membership/membership.controller.ts | 30 + src/modules/membership/membership.docs.ts | 24 + src/modules/membership/membership.model.ts | 66 ++ src/modules/membership/membership.service.ts | 114 +++ src/modules/membership/membership.types.ts | 44 + .../membership/membership.validators.ts | 13 + .../membership/routes/membership.v1.routes.ts | 16 + .../integration/acceptInvite.v1.test.ts | 187 +++++ .../integration/createOrganization.v1.test.ts | 391 +++++++++ .../integration/getOrganization.v1.test.ts | 219 +++++ .../getOrganizationMembers.v1.test.ts | 427 ++++++++++ .../integration/inviteMember.v1.test.ts | 763 ++++++++++++++++++ .../organization/organization.controller.ts | 236 ++++++ src/modules/organization/organization.docs.ts | 78 ++ .../organization/organization.model.ts | 5 +- .../organization/organization.service.ts | 334 ++++++++ .../organization/organization.types.ts | 84 +- .../organization/organization.validators.ts | 26 + .../routes/organization.v1.routes.ts | 53 ++ .../organization/utils/invitationEmail.ts | 47 ++ .../__tests__/integration/project.v1.test.ts | 108 +++ src/modules/project/project.controller.ts | 105 +++ src/modules/project/project.docs.ts | 59 ++ src/modules/project/project.model.ts | 46 ++ src/modules/project/project.service.ts | 133 +++ src/modules/project/project.types.ts | 44 + src/modules/project/project.validators.ts | 27 + .../project/routes/project.v1.routes.ts | 44 + .../tag/__tests__/integration/tag.v1.test.ts | 233 ++++++ src/modules/tag/routes/tag.v1.routes.ts | 41 + src/modules/tag/tag.controller.ts | 91 +++ src/modules/tag/tag.docs.ts | 55 ++ src/modules/tag/tag.model.ts | 37 + src/modules/tag/tag.service.ts | 127 +++ src/modules/tag/tag.types.ts | 36 + src/modules/tag/tag.validators.ts | 23 + .../__tests__/integration/task.v1.test.ts | 130 +++ src/modules/task/routes/task.v1.routes.ts | 41 + src/modules/task/task.controller.ts | 109 +++ src/modules/task/task.docs.ts | 55 ++ src/modules/task/task.model.ts | 42 + src/modules/task/task.service.ts | 130 +++ src/modules/task/task.types.ts | 40 + src/modules/task/task.validators.ts | 19 + .../integration/time-entry.v1.test.ts | 198 +++++ .../time-entry/routes/time-entry.v1.routes.ts | 53 ++ .../time-entry/time-entry.controller.ts | 169 ++++ src/modules/time-entry/time-entry.docs.ts | 94 +++ src/modules/time-entry/time-entry.model.ts | 80 ++ src/modules/time-entry/time-entry.service.ts | 273 +++++++ src/modules/time-entry/time-entry.types.ts | 66 ++ .../time-entry/time-entry.validators.ts | 57 ++ src/modules/user/user.model.ts | 17 +- src/modules/user/user.service.ts | 15 + src/modules/user/user.types.ts | 5 +- src/routes/v1.route.ts | 14 + src/server.ts | 3 +- src/tests/helpers/seed.ts | 47 +- src/types/express.d.ts | 4 + src/types/index.ts | 6 + src/utils/AppError.ts | 4 + src/utils/validators.ts | 11 + tsconfig.json | 3 +- 106 files changed, 7548 insertions(+), 201 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 implementation_plan.md create mode 100644 prioritized_checklist.md create mode 100644 render.yaml create mode 100644 src/__tests__/integration/cors.v1.test.ts create mode 100644 src/__tests__/integration/health.v1.test.ts create mode 100644 src/__tests__/integration/rateLimit.v1.test.ts create mode 100644 src/__tests__/integration/security.v1.test.ts create mode 100644 src/config/logger.ts create mode 100644 src/constants/index.ts create mode 100644 src/docs/health.docs.ts create mode 100644 src/middlewares/requireRole.ts create mode 100644 src/modules/client/__tests__/integration/client.v1.test.ts create mode 100644 src/modules/client/client.controller.ts create mode 100644 src/modules/client/client.docs.ts create mode 100644 src/modules/client/client.model.ts create mode 100644 src/modules/client/client.service.ts create mode 100644 src/modules/client/client.types.ts create mode 100644 src/modules/client/client.validators.ts create mode 100644 src/modules/client/routes/client.v1.routes.ts create mode 100644 src/modules/membership/membership.controller.ts create mode 100644 src/modules/membership/membership.docs.ts create mode 100644 src/modules/membership/membership.model.ts create mode 100644 src/modules/membership/membership.service.ts create mode 100644 src/modules/membership/membership.types.ts create mode 100644 src/modules/membership/membership.validators.ts create mode 100644 src/modules/membership/routes/membership.v1.routes.ts create mode 100644 src/modules/organization/__tests__/integration/acceptInvite.v1.test.ts create mode 100644 src/modules/organization/__tests__/integration/createOrganization.v1.test.ts create mode 100644 src/modules/organization/__tests__/integration/getOrganization.v1.test.ts create mode 100644 src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts create mode 100644 src/modules/organization/__tests__/integration/inviteMember.v1.test.ts create mode 100644 src/modules/organization/organization.controller.ts create mode 100644 src/modules/organization/organization.docs.ts create mode 100644 src/modules/organization/organization.service.ts create mode 100644 src/modules/organization/organization.validators.ts create mode 100644 src/modules/organization/routes/organization.v1.routes.ts create mode 100644 src/modules/organization/utils/invitationEmail.ts create mode 100644 src/modules/project/__tests__/integration/project.v1.test.ts create mode 100644 src/modules/project/project.controller.ts create mode 100644 src/modules/project/project.docs.ts create mode 100644 src/modules/project/project.model.ts create mode 100644 src/modules/project/project.service.ts create mode 100644 src/modules/project/project.types.ts create mode 100644 src/modules/project/project.validators.ts create mode 100644 src/modules/project/routes/project.v1.routes.ts create mode 100644 src/modules/tag/__tests__/integration/tag.v1.test.ts create mode 100644 src/modules/tag/routes/tag.v1.routes.ts create mode 100644 src/modules/tag/tag.controller.ts create mode 100644 src/modules/tag/tag.docs.ts create mode 100644 src/modules/tag/tag.model.ts create mode 100644 src/modules/tag/tag.service.ts create mode 100644 src/modules/tag/tag.types.ts create mode 100644 src/modules/tag/tag.validators.ts create mode 100644 src/modules/task/__tests__/integration/task.v1.test.ts create mode 100644 src/modules/task/routes/task.v1.routes.ts create mode 100644 src/modules/task/task.controller.ts create mode 100644 src/modules/task/task.docs.ts create mode 100644 src/modules/task/task.model.ts create mode 100644 src/modules/task/task.service.ts create mode 100644 src/modules/task/task.types.ts create mode 100644 src/modules/task/task.validators.ts create mode 100644 src/modules/time-entry/__tests__/integration/time-entry.v1.test.ts create mode 100644 src/modules/time-entry/routes/time-entry.v1.routes.ts create mode 100644 src/modules/time-entry/time-entry.controller.ts create mode 100644 src/modules/time-entry/time-entry.docs.ts create mode 100644 src/modules/time-entry/time-entry.model.ts create mode 100644 src/modules/time-entry/time-entry.service.ts create mode 100644 src/modules/time-entry/time-entry.types.ts create mode 100644 src/modules/time-entry/time-entry.validators.ts create mode 100644 src/utils/validators.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8562f2b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +.env +.git +.github +.husky +*.md +jest.config.mjs +eslint.config.mjs +.prettierrc +openapi.json +**/__tests__ +src/tests diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index cb92201..6c06671 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,5 +1,4 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs +# Timesheets CI — runs affected tests on PRs, full suite on merge to main/develop name: Timesheets CI @@ -15,16 +14,29 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [20.x] steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed for --changedSince to compare branches + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: "npm" + - run: npm ci - run: npm run build --if-present - - run: npm test + + # PRs: only run tests affected by changed files + - name: Run affected tests (PR) + if: github.event_name == 'pull_request' + run: npx jest --runInBand --changedSince=origin/${{ github.base_ref }} + + # Pushes to main/develop: run the full test suite + - name: Run all tests (push) + if: github.event_name == 'push' + run: npm test + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24a4a22 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# ── Stage 1: Build ──────────────────────────────────────────── +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies first (layer caching) +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy source and compile TypeScript + resolve path aliases +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# ── Stage 2: Production ────────────────────────────────────── +FROM node:20-alpine + +WORKDIR /app + +# Install only production dependencies +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +# Copy compiled output from builder +COPY --from=builder /app/dist ./dist + +# Run as non-root for security +USER node + +EXPOSE 5000 + +CMD ["node", "dist/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2c61b6b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + mongo: + image: mongo:7 + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + + api: + build: . + ports: + - "5000:5000" + depends_on: + - mongo + env_file: + - .env + environment: + - MONGODB_URI=mongodb://mongo:27017/timesheets + +volumes: + mongo_data: diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000..c1fe218 --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,52 @@ +# Production-Grade Timesheets MVP Implementation Plan + +The current codebase establishes a solid foundation with Auth, Organization, and Membership modules. To reach a "production-grade MVP" similar to Clockify, several core modules and infrastructure enhancements are required. + +## Proposed Changes + +### 1. Core Runtime Modules +New modules to handle the actual timesheet logic. + +#### [NEW] Time Entry Module +- **Model**: Track `description`, `startTime`, `endTime`, `duration`, `billable` status, and links to User, Org, Project, and Task. +- **Features**: Timer start/stop logic, manual entry CRUD, bulk deletes, and basic validation (e.g., end time > start time). + +#### [NEW] Project & Client Modules +- **Project**: Group entries by project. Track `color`, `billableRate`, and `visibility`. +- **Client**: Group projects by client for better organization and billing. + +#### [NEW] Task & Tag Modules +- **Task**: Granular work items within a project. +- **Tag**: Cross-project categorization. + +#### [NEW] Reporting Module +- **Features**: Summary and Detailed reports. +- **Aggregation**: Grouping time entries by Project, User, or Client over specific date ranges. + +### 2. Infrastructure & Security +Enhancements to make the backend production-ready. + +#### [MODIFY] Security & Middleware +- **Helmet**: Add `helmet` for secure HTTP headers. +- **Rate Limiting**: Implement `express-rate-limit` to prevent brute-force and DDoS. +- **CORS**: Tighten CORS policy for production environments. + +#### [MODIFY] Observability +- **Structured Logging**: Replace/Supplement `morgan` with `pino` or `winston` for JSON logging. +- **Health Checks**: Add `/health` endpoint for monitoring and orchestration. + +#### [NEW] DevOps +- **Docker**: Add `Dockerfile` and `docker-compose.yml` for consistent development and deployment. + +## Verification Plan + +### Automated Tests +- **Unit Tests**: For each new service (TimeEntry, Project, etc.). +- **Integration Tests**: End-to-end flows like: + 1. User signs up -> Creates Org -> Creates Project -> Starts Timer -> Stops Timer. + 2. Owner invites member -> Member joins -> Member logs time -> Owner views report. +- **Command**: `npm run test` (or `yarn test`). + +### Manual Verification +1. **API Testing**: Use Postman/Insomnia to verify new endpoints against `openapi.json` specs. +2. **Email Flow**: Verify invitation and password reset emails in a staging/dev environment. diff --git a/jest.config.mjs b/jest.config.mjs index c05de4e..30251b0 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -20,5 +20,6 @@ export default { "^@middlewares/(.*)$": "/src/middlewares/$1", "^@docs/(.*)$": "/src/docs/$1", "^@services/(.*)$": "/src/services/$1", + "^@constants$": "/src/constants/index.ts", }, }; diff --git a/package-lock.json b/package-lock.json index 4daf452..c83f90c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@faker-js/faker": "^10.1.0", "@types/cookie-parser": "^1.4.10", - "@types/morgan": "^1.9.10", "bcryptjs": "^3.0.2", "cookie": "^1.1.1", "cookie-parser": "^1.4.7", @@ -19,9 +18,12 @@ "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.19.2", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "mongoose": "^8.5.0", - "morgan": "^1.10.1", + "pino": "^10.3.1", + "pino-http": "^11.0.0", "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", @@ -34,14 +36,17 @@ "@types/cookie-signature": "^1.1.2", "@types/cors": "^2.8.19", "@types/express": "^4.17.21", + "@types/helmet": "^0.0.48", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/pino-http": "^5.8.4", "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", + "pino-pretty": "^13.1.3", "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5", @@ -1636,6 +1641,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1867,6 +1878,16 @@ "@types/send": "*" } }, + "node_modules/@types/helmet": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.48.tgz", + "integrity": "sha512-C7MpnvSDrunS1q2Oy1VWCY7CDWHozqSnM8P4tFeRTuzwqni+PYOjEredwcqWG+kLpYcgLsgcY3orHB54gbx2Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -1950,15 +1971,6 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, - "node_modules/@types/morgan": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", - "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1975,6 +1987,60 @@ "undici-types": "~7.14.0" } }, + "node_modules/@types/pino": { + "version": "6.3.12", + "resolved": "https://registry.npmjs.org/@types/pino/-/pino-6.3.12.tgz", + "integrity": "sha512-dsLRTq8/4UtVSpJgl9aeqHvbh6pzdmjYD3C092SYgLD2TyoCqHpTJk6vp8DvCTGGc7iowZ2MoiYiVUUCcu7muw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pino-pretty": "*", + "@types/pino-std-serializers": "*", + "sonic-boom": "^2.1.0" + } + }, + "node_modules/@types/pino-http": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/@types/pino-http/-/pino-http-5.8.4.tgz", + "integrity": "sha512-UTYBQ2acmJ2eK0w58vVtgZ9RAicFFndfrnWC1w5cBTf8zwn/HEy8O+H7psc03UZgTzHmlcuX8VkPRnRDEj+FUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pino": "6.3" + } + }, + "node_modules/@types/pino-pretty": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@types/pino-pretty/-/pino-pretty-4.7.5.tgz", + "integrity": "sha512-rfHe6VIknk14DymxGqc9maGsRe8/HQSvM2u46EAz2XrS92qsAJnW16dpdFejBuZKD8cRJX6Aw6uVZqIQctMpAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pino": "6.3" + } + }, + "node_modules/@types/pino-std-serializers": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@types/pino-std-serializers/-/pino-std-serializers-2.4.1.tgz", + "integrity": "sha512-17XcksO47M24IVTVKPeAByWUd3Oez7EbIjXpSbzMPhXVzgjGtrOa49gKBwxH9hb8dKv58OelsWQ+A1G1l9S3wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pino/node_modules/sonic-boom": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz", + "integrity": "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2820,6 +2886,15 @@ "dev": true, "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/b4a": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", @@ -2965,24 +3040,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/bcryptjs": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", @@ -3372,6 +3429,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3527,6 +3591,16 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3741,6 +3815,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4117,6 +4201,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -4147,6 +4249,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4763,6 +4872,22 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4950,6 +5075,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5882,6 +6016,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6461,49 +6605,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", - "license": "MIT", - "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.1.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/morgan/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/morgan/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -6697,6 +6798,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6709,15 +6819,6 @@ "node": ">= 0.8" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6965,6 +7066,93 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", + "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^10.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -7111,6 +7299,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7124,6 +7328,17 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7196,6 +7411,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7240,6 +7461,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7403,6 +7633,23 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -7602,6 +7849,15 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7632,6 +7888,15 @@ "memory-pager": "^1.0.2" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -7936,6 +8201,18 @@ "b4a": "^1.6.4" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index 77045b4..632e76d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "dependencies": { "@faker-js/faker": "^10.1.0", "@types/cookie-parser": "^1.4.10", - "@types/morgan": "^1.9.10", "bcryptjs": "^3.0.2", "cookie": "^1.1.1", "cookie-parser": "^1.4.7", @@ -26,9 +25,12 @@ "cors": "^2.8.5", "dotenv": "^16.6.1", "express": "^4.19.2", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "mongoose": "^8.5.0", - "morgan": "^1.10.1", + "pino": "^10.3.1", + "pino-http": "^11.0.0", "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", @@ -41,14 +43,17 @@ "@types/cookie-signature": "^1.1.2", "@types/cors": "^2.8.19", "@types/express": "^4.17.21", + "@types/helmet": "^0.0.48", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/pino-http": "^5.8.4", "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.8", "eslint": "^9.37.0", "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", + "pino-pretty": "^13.1.3", "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5", diff --git a/prioritized_checklist.md b/prioritized_checklist.md new file mode 100644 index 0000000..abd05ab --- /dev/null +++ b/prioritized_checklist.md @@ -0,0 +1,40 @@ +# Implementation Checklist (Easiest to Most Complex) + +This checklist breaks down the [Implementation Plan](file:///Users/exploit/Desktop/projects/timesheets/timesheets-backend/implementation_plan.md) into actionable steps, starting with low-hanging fruit. + +### Phase 1: Infrastructure & Security (Quick Wins) +- [x] **Health Check Endpoint**: Add a simple GET `/health` route to verify server status. +- [x] **Security Headers**: Install and configure `helmet` middleware. +- [x] **CORS Configuration**: Restrict CORS to allowed origins for production. +- [x] **Rate Limiting**: Add `express-rate-limit` to sensitive routes (Auth, Invitations). + +### Phase 2: Metadata Modules (Foundation) +- [x] **Tag Module**: Implement basic CRUD (Create, Read, Update, Delete) for tags. +- [x] **Client Module**: Implement CRUD for clients (name, email, address, etc.). +- [x] **Project Module**: Implement CRUD for projects (Name, Color, Billable status, link to Client). +- [x] **Task Module**: Implement CRUD for tasks within projects. + +### Phase 3: Core Logic (The Meat) +- [x] **Time Entry Model**: Define the Mongoose schema for time entries. +- [x] **Manual Time Entry**: Multi-step implementation: + - [x] Create manual time entry (POST). + - [x] List user time entries (GET). + - [x] Update/Delete entry (PATCH/DELETE). +- [x] **Timer Implementation**: + - [x] Start timer (POST - sets `startTime`, `endTime` is null). + - [x] Stop timer (PATCH - calculates `duration` and sets `endTime`). + - [x] Prevent multiple active timers per user. + +### Phase 4: Observability & DevOps +- [x] **Structured Logging**: Integrate `pino` or `winston` and replace existing `console.log` calls. +- [x] **Dockerization**: Create a `Dockerfile` and `docker-compose.yml` with MongoDB environment setup. + +### Phase 5: Advanced Features +- [ ] **Reporting Engine**: + - [ ] Implement data aggregation by Project. + - [ ] Implement data aggregation by User/Member. + - [ ] Export reports (CSV/JSON). + +### Phase 6: Polish & Verification +- [ ] **Expand Test Coverage**: Add integration tests for complete end-to-end flows. +- [ ] **Documentation**: Ensure `openapi.json` is updated with all new endpoints. diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..39a529a --- /dev/null +++ b/render.yaml @@ -0,0 +1,38 @@ +services: + - type: web + name: timesheets-api + runtime: docker + plan: free + branch: main + healthCheckPath: /health + envVars: + - key: NODE_ENV + value: production + - key: PORT + value: "5000" + - key: MONGODB_URI + sync: false + - key: JWT_SECRET + sync: false + - key: JWT_ACCESS_EXPIRES_IN + sync: false + - key: JWT_REFRESH_EXPIRES_IN + sync: false + - key: REFRESH_TOKEN_BYTES + sync: false + - key: COOKIE_SECRET + sync: false + - key: ZEPTO_MAIL_TOKEN + sync: false + - key: ZEPTO_MAIL_URL + sync: false + - key: FROM_EMAIL + sync: false + - key: SUPPORT_EMAIL + sync: false + - key: FROM_NAME + sync: false + - key: EMAIL_VERIFICATION_TEMPLATE_KEY + sync: false + - key: PASSWORD_RESET_TEMPLATE_KEY + sync: false diff --git a/src/__tests__/integration/cors.v1.test.ts b/src/__tests__/integration/cors.v1.test.ts new file mode 100644 index 0000000..7915c83 --- /dev/null +++ b/src/__tests__/integration/cors.v1.test.ts @@ -0,0 +1,23 @@ +import request from "supertest"; +import app from "@app"; + +describe("CORS Configuration", () => { + const allowedOrigin = "http://localhost:3000"; + const disallowedOrigin = "http://malicious-site.com"; + + it("should allow requests from an allowed origin", async () => { + const res = await request(app) + .get("/api/health") + .set("Origin", allowedOrigin); + + expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); + }); + + it("should NOT allow requests from unauthorized origins", async () => { + const res = await request(app) + .get("/api/health") + .set("Origin", disallowedOrigin); + + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); +}); diff --git a/src/__tests__/integration/health.v1.test.ts b/src/__tests__/integration/health.v1.test.ts new file mode 100644 index 0000000..a19d4ce --- /dev/null +++ b/src/__tests__/integration/health.v1.test.ts @@ -0,0 +1,11 @@ +import request from "supertest"; +import app from "@app"; + +describe("GET /api/health", () => { + it("should return 200 and status ok", async () => { + const res = await request(app).get("/api/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "ok" }); + }); +}); diff --git a/src/__tests__/integration/rateLimit.v1.test.ts b/src/__tests__/integration/rateLimit.v1.test.ts new file mode 100644 index 0000000..0421911 --- /dev/null +++ b/src/__tests__/integration/rateLimit.v1.test.ts @@ -0,0 +1,15 @@ +import request from "supertest"; +import app from "@app"; + +describe("Rate Limiting", () => { + it("should return 429 when rate limit is exceeded", async () => { + const requests = Array.from({ length: 110 }, () => + request(app).get("/api/health"), + ); + + const responses = await Promise.all(requests); + const tooManyRequests = responses.filter((res) => res.status === 429); + + expect(tooManyRequests.length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/integration/security.v1.test.ts b/src/__tests__/integration/security.v1.test.ts new file mode 100644 index 0000000..030cfde --- /dev/null +++ b/src/__tests__/integration/security.v1.test.ts @@ -0,0 +1,17 @@ +import request from "supertest"; +import app from "@app"; + +describe("Security Headers", () => { + it("should have various security headers set by helmet", async () => { + const res = await request(app).get("/api/health"); + + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + expect(res.headers["x-dns-prefetch-control"]).toBe("off"); + expect(res.headers["expect-ct"]).toBeUndefined(); // This one might vary depending on helmet version + expect(res.headers["x-frame-options"]).toBe("SAMEORIGIN"); + expect(res.headers["strict-transport-security"]).toBeDefined(); + expect(res.headers["x-download-options"]).toBe("noopen"); + expect(res.headers["x-permitted-cross-domain-policies"]).toBe("none"); + expect(res.headers["referrer-policy"]).toBe("no-referrer"); + }); +}); diff --git a/src/app.ts b/src/app.ts index 3d741c0..d65f394 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,18 +1,45 @@ import express, { Application, NextFunction, Request, Response } from "express"; +import helmet from "helmet"; +import { rateLimit } from "express-rate-limit"; import cors from "cors"; -import morgan from "morgan"; +import { httpLogger } from "@config/logger"; import v1Router from "./routes/v1.route"; import errorHandler from "./middlewares/errorHandler"; import cookieParser from "cookie-parser"; -import { COOKIE_SECRET } from "@config/env"; +import { COOKIE_SECRET, FRONTEND_BASE_URL } from "@config/env"; import swaggerUi from "swagger-ui-express"; + const app: Application = express(); +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 100, + standardHeaders: "draft-7", + legacyHeaders: false, + message: { + success: false, + error: "Too many requests, please try again later.", + }, +}); + +app.use(helmet()); +app.use(limiter); app.set("trust proxy", 1); -app.use(cors()); +app.use( + cors({ + origin: (origin, callback) => { + if (!origin || origin === FRONTEND_BASE_URL) { + callback(null, true); + } else { + callback(null, false); + } + }, + credentials: true, + }), +); app.use(cookieParser(COOKIE_SECRET)); app.use(express.json()); -app.use(morgan("dev")); +app.use(httpLogger); app.use("/api/v1", v1Router); export function mountSwagger(spec: object) { diff --git a/src/config/db.ts b/src/config/db.ts index 592088e..dc02525 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -1,12 +1,13 @@ import mongoose from "mongoose"; import { MONGODB_URI } from "./env"; +import { logger } from "./logger"; const connectDB = async () => { try { const conn = await mongoose.connect(MONGODB_URI as string); - console.log(`✅ MongoDB Connected: ${conn.connection.host}`); + logger.info({ host: conn.connection.host }, "MongoDB connected"); } catch (error) { - console.error("🚨 MongoDB connection failed:", error); + logger.fatal({ err: error }, "MongoDB connection failed"); process.exit(1); } }; diff --git a/src/config/env.ts b/src/config/env.ts index 1d7dd53..ba8379b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -16,6 +16,8 @@ const envSchema = z.object({ COOKIE_SECRET: z.string(), EMAIL_VERIFICATION_TEMPLATE_KEY: z.string(), PASSWORD_RESET_TEMPLATE_KEY: z.string(), + INVITATION_TEMPLATE_KEY: z.string(), + FRONTEND_BASE_URL: z.string().url(), }); const isTest = process.env.NODE_ENV === "test"; @@ -37,6 +39,8 @@ const env = isTest COOKIE_SECRET: "testsecret", EMAIL_VERIFICATION_TEMPLATE_KEY: "", PASSWORD_RESET_TEMPLATE_KEY: "", + INVITATION_TEMPLATE_KEY: "", + FRONTEND_BASE_URL: "http://localhost:3000", }, error: envSchema.safeParse(process.env).error, } @@ -61,4 +65,6 @@ export const { COOKIE_SECRET, EMAIL_VERIFICATION_TEMPLATE_KEY, PASSWORD_RESET_TEMPLATE_KEY, + INVITATION_TEMPLATE_KEY, + FRONTEND_BASE_URL, } = env.data; diff --git a/src/config/logger.ts b/src/config/logger.ts new file mode 100644 index 0000000..899ff42 --- /dev/null +++ b/src/config/logger.ts @@ -0,0 +1,28 @@ +import pino from "pino"; +import pinoHttp from "pino-http"; + +const isProduction = process.env.NODE_ENV === "production"; +const isTest = process.env.NODE_ENV === "test"; + +export const logger = pino({ + level: isTest ? "silent" : isProduction ? "info" : "debug", + ...(isProduction + ? {} + : { + transport: { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "HH:MM:ss", + ignore: "pid,hostname", + }, + }, + }), +}); + +export const httpLogger = pinoHttp({ + logger, + autoLogging: { + ignore: (req) => (req.url ?? "").includes("/health"), + }, +}); diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..d821ca0 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,37 @@ +export const USER_ROLES = { + OWNER: "OWNER", + MANAGER: "MANAGER", + MEMBER: "MEMBER", + VIEWER: "VIEWER", +} as const; + +export const TASK_STATUS = { + TODO: "TODO", + IN_PROGRESS: "IN_PROGRESS", + DONE: "DONE", + ARCHIVED: "ARCHIVED", + ACTIVE: "ACTIVE", // Legacy default +} as const; + +export const GLOBAL_STATUS = { + ACTIVE: "ACTIVE", + ARCHIVED: "ARCHIVED", +} as const; + +export const ORG_STATUS = { + ACTIVE: "ACTIVE", + INACTIVE: "INACTIVE", +} as const; + +export const MEMBERSHIP_STATUS = { + ACTIVE: "ACTIVE", + DISABLED: "DISABLED", + PENDING: "PENDING", +} as const; + +export type UserRole = (typeof USER_ROLES)[keyof typeof USER_ROLES]; +export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]; +export type GlobalStatus = (typeof GLOBAL_STATUS)[keyof typeof GLOBAL_STATUS]; +export type OrgStatus = (typeof ORG_STATUS)[keyof typeof ORG_STATUS]; +export type MembershipStatus = + (typeof MEMBERSHIP_STATUS)[keyof typeof MEMBERSHIP_STATUS]; diff --git a/src/docs/health.docs.ts b/src/docs/health.docs.ts new file mode 100644 index 0000000..10a0fa6 --- /dev/null +++ b/src/docs/health.docs.ts @@ -0,0 +1,16 @@ +import { Tspec } from "tspec"; + +export type HealthApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api"; + tags: ["Health"]; + paths: { + "/health": { + get: { + summary: "Check API health status"; + responses: { + 200: { status: "ok" }; + }; + }; + }; + }; +}>; diff --git a/src/middlewares/authenticate.ts b/src/middlewares/authenticate.ts index 9644b63..8339435 100644 --- a/src/middlewares/authenticate.ts +++ b/src/middlewares/authenticate.ts @@ -24,6 +24,7 @@ const authenticate = async ( } req.user = user; + next(); } catch (err) { return next( diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index bae7bac..7226c6d 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from "express"; import AppError from "../utils/AppError"; import { NODE_ENV } from "@config/env"; +import { logger } from "@config/logger"; import { MongooseError } from "mongoose"; const errorHandler = ( @@ -10,9 +11,6 @@ const errorHandler = ( // eslint-disable-next-line @typescript-eslint/no-unused-vars next?: NextFunction, ) => { - if (!(err instanceof AppError)) { - console.log(err); - } if (err instanceof AppError) { return res.status(err.statusCode).json({ success: false, @@ -22,12 +20,15 @@ const errorHandler = ( } if (err instanceof Error || err instanceof MongooseError) { + logger.error({ err }, "Unhandled error"); return res.status(500).json({ success: false, error: err.message || "Something went wrong", stack: NODE_ENV === "development" ? err.stack : "", }); } + + logger.error({ err }, "Unhandled non-Error thrown"); return res.status(500).json({ success: false, error: "Something went wrong", diff --git a/src/middlewares/requireRole.ts b/src/middlewares/requireRole.ts new file mode 100644 index 0000000..68f9fb7 --- /dev/null +++ b/src/middlewares/requireRole.ts @@ -0,0 +1,43 @@ +import { Request, Response, NextFunction } from "express"; +import AppError from "@utils/AppError"; +import { IUser } from "@modules/user/user.types"; +import OrganizationService from "@modules/organization/organization.service"; +import { UserRole } from "@constants"; +import { ISuccessPayload } from "src/types"; +import { IOrganization } from "@modules/organization/organization.types"; + +const requireRole = (allowedRoles: UserRole[]) => { + return async (req: Request, _res: Response, next: NextFunction) => { + const user = req.user as IUser; + + if (!user) { + return next(AppError.unauthorized("User not found")); + } + + const orgResult = await OrganizationService.getUserOrganization( + user._id.toString(), + ); + + if (!orgResult.success) { + return next(AppError.notFound("User does not have an organization")); + } + + const successfulOrgResult = orgResult as ISuccessPayload<{ + organization: IOrganization; + role: UserRole; + }>; + + if (!allowedRoles.includes(successfulOrgResult.data.role)) { + return next( + AppError.forbidden( + `Access denied. Required roles: ${allowedRoles.join(", ")}`, + ), + ); + } + req.userOrg = successfulOrgResult.data.organization; + req.userRole = successfulOrgResult.data.role; + next(); + }; +}; + +export default requireRole; diff --git a/src/middlewares/validators.ts b/src/middlewares/validators.ts index 788bce8..0c80132 100644 --- a/src/middlewares/validators.ts +++ b/src/middlewares/validators.ts @@ -3,9 +3,11 @@ import { Request, Response, NextFunction } from "express"; import AppError from "@utils/AppError"; const validateResource = - (schema: ZodSchema) => (req: Request, _res: Response, next: NextFunction) => { + (schema: ZodSchema, source: "body" | "query" = "body") => + (req: Request, _res: Response, next: NextFunction) => { try { - schema.parse(req.body); + const data = source === "query" ? req.query : req.body; + schema.parse(data); next(); } catch (e: unknown) { return next( diff --git a/src/modules/auth/__tests__/integration/changePassword.v1.test.ts b/src/modules/auth/__tests__/integration/changePassword.v1.test.ts index b818219..ff23af4 100644 --- a/src/modules/auth/__tests__/integration/changePassword.v1.test.ts +++ b/src/modules/auth/__tests__/integration/changePassword.v1.test.ts @@ -3,7 +3,7 @@ import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { compareHashedBcryptString } from "@utils/encryptors"; import { TEST_CONSTANTS, @@ -50,7 +50,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if currentPassword is missing", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -72,7 +72,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if newPassword is missing", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -94,7 +94,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if newPassword is less than 6 characters", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -117,7 +117,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if newPassword is the same as currentPassword", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -140,7 +140,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should return 400 if current password is incorrect", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -164,7 +164,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should successfully change password with valid credentials", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -188,7 +188,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should update password in database after successful change", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -208,7 +208,7 @@ describe("POST /api/v1/auth/change-password", () => { expect(res.status).toBe(200); - const updatedUser = await UserModel.findById(user._id); + const updatedUser = await UserService.getUserById(user._id.toString()); if (!updatedUser) throw new Error("User not found"); const isNewPasswordValid = await compareHashedBcryptString( @@ -225,7 +225,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should allow login with new password after change", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -253,7 +253,7 @@ describe("POST /api/v1/auth/change-password", () => { }); it("should not allow login with old password after change", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ diff --git a/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts b/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts index 2626b8e..e7482b5 100644 --- a/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts +++ b/src/modules/auth/__tests__/integration/forgotPassword.v1.test.ts @@ -3,9 +3,8 @@ import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { sendEmailWithTemplate } from "@services/email.service"; -import UserModel from "@modules/user/user.model"; import { TEST_CONSTANTS } from "../helpers/testHelpers"; - +import UserService from "@modules/user/user.service"; jest.mock("@services/email.service"); const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; @@ -88,7 +87,7 @@ describe("POST /api/v1/auth/forgot-password", () => { .post("/api/v1/auth/forgot-password") .send({ email: verifiedUserEmail }); - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); expect(user).toBeTruthy(); expect(user?.passwordResetCode).toBeTruthy(); expect(user?.passwordResetCodeExpiry).toBeTruthy(); @@ -160,9 +159,8 @@ describe("POST /api/v1/auth/forgot-password", () => { expect(firstRequest.status).toBe(200); - const userAfterFirst = await UserModel.findOne({ - email: verifiedUserEmail, - }); + const userAfterFirst = await UserService.getUserByEmail(verifiedUserEmail); + const firstExpiry = userAfterFirst?.passwordResetCodeExpiry; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -171,9 +169,7 @@ describe("POST /api/v1/auth/forgot-password", () => { .post("/api/v1/auth/forgot-password") .send({ email: verifiedUserEmail }); - const userAfterSecond = await UserModel.findOne({ - email: verifiedUserEmail, - }); + const userAfterSecond = await UserService.getUserByEmail(verifiedUserEmail); const secondExpiry = userAfterSecond?.passwordResetCodeExpiry; expect(secondExpiry?.getTime()).toBeGreaterThan( diff --git a/src/modules/auth/__tests__/integration/logout.v1.test.ts b/src/modules/auth/__tests__/integration/logout.v1.test.ts index d2c2b75..2090d33 100644 --- a/src/modules/auth/__tests__/integration/logout.v1.test.ts +++ b/src/modules/auth/__tests__/integration/logout.v1.test.ts @@ -7,7 +7,7 @@ import { extractSignedCookie, } from "@tests/utils"; import { RefreshTokenModel } from "@modules/auth/refreshToken.model"; -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { TEST_CONSTANTS } from "../helpers/testHelpers"; const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; @@ -109,7 +109,7 @@ describe("Auth Logout", () => { ); if (!refreshTokenValue) throw new Error("Refresh token cookie not found"); - const userDoc = await UserModel.findOne({ email: verifiedUserEmail }); + const userDoc = await UserService.getUserByEmail(verifiedUserEmail); if (!userDoc) throw new Error("User not found"); const tokenDoc = await RefreshTokenModel.findOne({ diff --git a/src/modules/auth/__tests__/integration/me.v1.test.ts b/src/modules/auth/__tests__/integration/me.v1.test.ts index 9e520a8..ba3b50a 100644 --- a/src/modules/auth/__tests__/integration/me.v1.test.ts +++ b/src/modules/auth/__tests__/integration/me.v1.test.ts @@ -3,13 +3,13 @@ import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; -import UserModel from "@modules/user/user.model"; import * as jwt from "jsonwebtoken"; import * as signature from "cookie-signature"; import { TEST_CONSTANTS, createSignedAccessTokenCookie, } from "../helpers/testHelpers"; +import UserService from "@modules/user/user.service"; const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; @@ -44,7 +44,7 @@ describe("GET /api/v1/auth/me", () => { }); it("should return 401 if access token has invalid signature", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -77,7 +77,7 @@ describe("GET /api/v1/auth/me", () => { }); it("should return 401 if access token is expired", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); // Create an expired token @@ -117,7 +117,7 @@ describe("GET /api/v1/auth/me", () => { }); it("should return 200 with user data when valid access token is provided", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -138,7 +138,7 @@ describe("GET /api/v1/auth/me", () => { }); it("should return correct user data structure", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const accessToken = generateAccessToken({ @@ -158,7 +158,6 @@ describe("GET /api/v1/auth/me", () => { expect(userData.id).toBe(user._id.toString()); expect(userData.email).toBe(user.email); - expect(userData.role).toBe(user.role); expect(userData.isEmailVerified).toBe(user.isEmailVerified); expect(userData.createdAt).toBeDefined(); expect(userData.updatedAt).toBeDefined(); diff --git a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts index 55ce079..39963e9 100644 --- a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts +++ b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts @@ -6,11 +6,11 @@ import { RefreshTokenModel } from "@modules/auth/refreshToken.model"; import { hashWithCrypto } from "@utils/encryptors"; import { generateRandomTokenWithCrypto } from "@utils/generators"; import { convertTimeToMilliseconds } from "@utils/index"; -import UserModel from "@modules/user/user.model"; import * as cookie from "cookie"; import * as signature from "cookie-signature"; import { COOKIE_SECRET } from "@config/env"; import { TEST_CONSTANTS } from "../helpers/testHelpers"; +import UserService from "@modules/user/user.service"; const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; @@ -44,7 +44,7 @@ describe("Refresh Token", () => { }); it("should return 401 if refresh token is expired", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const rawRefreshToken = generateRandomTokenWithCrypto(64); @@ -65,7 +65,7 @@ describe("Refresh Token", () => { }); it("should return 401 if refresh token is revoked", async () => { - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (!user) throw new Error("User not found"); const rawRefreshToken = generateRandomTokenWithCrypto(64); diff --git a/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts b/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts index 03f545c..54f5670 100644 --- a/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts +++ b/src/modules/auth/__tests__/integration/resetPassword.v1.test.ts @@ -3,10 +3,10 @@ import app from "@app"; import { seedOneUserWithOrg } from "@tests/helpers/seed"; import { clearDB } from "@tests/utils"; import { sendEmailWithTemplate } from "@services/email.service"; -import UserModel from "@modules/user/user.model"; import { convertTimeToMilliseconds } from "@utils/index"; import { compareHashedBcryptString } from "@utils/encryptors"; import { TEST_CONSTANTS } from "../helpers/testHelpers"; +import UserService from "@modules/user/user.service"; jest.mock("@services/email.service"); @@ -157,7 +157,7 @@ describe("POST /api/v1/auth/reset-password", () => { .post("/api/v1/auth/forgot-password") .send({ email: verifiedUserEmail }); - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); if (user) { user.passwordResetCodeExpiry = new Date( Date.now() - convertTimeToMilliseconds(1, "min"), @@ -221,7 +221,7 @@ describe("POST /api/v1/auth/reset-password", () => { newPassword: newPassword, }); - const user = await UserModel.findOne({ email: verifiedUserEmail }); + const user = await UserService.getUserByEmail(verifiedUserEmail); expect(user).toBeTruthy(); const isNewPasswordValid = await compareHashedBcryptString( @@ -260,7 +260,7 @@ describe("POST /api/v1/auth/reset-password", () => { expect(resetRes.status).toBe(200); - const user = await UserModel.findOne({ email: verifiedUserEmail }).lean(); + const user = await UserService.getUserByEmail(verifiedUserEmail); expect(user).toBeTruthy(); expect(user?.passwordResetCode).toBeNull(); expect(user?.passwordResetCodeExpiry).toBeNull(); diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts index 57f7468..daa93fd 100644 --- a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -2,7 +2,7 @@ import request from "supertest"; import app from "@app"; import { UserFactory } from "@tests/factories/user.factory"; import { sendEmailWithTemplate } from "@services/email.service"; -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { convertTimeToMilliseconds } from "@utils/index"; import { clearDB } from "@tests/utils"; @@ -45,7 +45,7 @@ describe("Email Verification", () => { await request(app).post("/api/v1/auth/signup").send(user); - const userInDb = await UserModel.findOne({ email: user.email }); + const userInDb = await UserService.getUserByEmail(user.email); if (userInDb) { userInDb.emailVerificationCodeExpiry = new Date( Date.now() - convertTimeToMilliseconds(1, "min"), diff --git a/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts b/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts index fd2d914..f818e56 100644 --- a/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts +++ b/src/modules/auth/__tests__/unit/resetPassword.unit.test.ts @@ -1,24 +1,22 @@ -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { UserFactory } from "@tests/factories/user.factory"; import { convertTimeToMilliseconds } from "@utils/index"; describe("Password Reset Code Logic", () => { it("successfully generates a hashed password reset code", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generatePasswordResetCode(); - await user.save(); + await user.save(); // Save to trigger pre-save hook that sets expiry expect(code).toHaveLength(6); expect(code).not.toBe(user.passwordResetCode); expect(user.passwordResetCodeExpiry).toBeDefined(); }); it("fails verification if code is wrong", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generatePasswordResetCode(); await user.save(); @@ -29,9 +27,8 @@ describe("Password Reset Code Logic", () => { }); it("fails verification if code is expired", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generatePasswordResetCode(); await user.save(); @@ -44,9 +41,8 @@ describe("Password Reset Code Logic", () => { }); it("successfully verifies code with correct code", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generatePasswordResetCode(); await user.save(); @@ -55,9 +51,8 @@ describe("Password Reset Code Logic", () => { }); it("clears password reset data after clearing", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); user.generatePasswordResetCode(); await user.save(); diff --git a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts index 7ed0cba..38c16fc 100644 --- a/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts +++ b/src/modules/auth/__tests__/unit/verifyEmail.unit.test.ts @@ -1,10 +1,10 @@ -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { UserFactory } from "@tests/factories/user.factory"; import { convertTimeToMilliseconds } from "@utils/index"; describe("Email Verification Code Logic", () => { it("successfully generates a hashed email verification code", async () => { - const user = new UserModel(UserFactory.generate()); + const user = await UserService.createUser(UserFactory.generate()); const code = user.generateEmailVerificationCode(); expect(code).toHaveLength(6); expect(code).not.toBe(user.emailVerificationCode); @@ -12,7 +12,7 @@ describe("Email Verification Code Logic", () => { }); it("fails verification if code is wrong", async () => { - const user = new UserModel(UserFactory.generate()); + const user = await UserService.createUser(UserFactory.generate()); const code = user.generateEmailVerificationCode(); const isCorrectCode = user.verifyEmailVerificationCode( code.split("").reverse().join(""), @@ -22,7 +22,7 @@ describe("Email Verification Code Logic", () => { }); it("fails verification if code is expired", async () => { - const user = new UserModel(UserFactory.generate()); + const user = await UserService.createUser(UserFactory.generate()); const code = user.generateEmailVerificationCode(); user.emailVerificationCodeExpiry = new Date( Date.now() - convertTimeToMilliseconds(60, "minutes"), @@ -33,9 +33,8 @@ describe("Email Verification Code Logic", () => { }); it("successfully verifies code with correct code", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); const code = user.generateEmailVerificationCode(); await user.save(); @@ -45,9 +44,8 @@ describe("Email Verification Code Logic", () => { }); it("clears verification data after clearing", async () => { - const user = new UserModel({ + const user = await UserService.createUser({ ...UserFactory.generate(), - role: "owner", }); user.generateEmailVerificationCode(); await user.save(); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 9df635a..2f25def 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,11 +1,11 @@ -import UserModel from "@modules/user/user.model"; +import UserService from "@modules/user/user.service"; import { + SignupInput, + SignupOutput, + SendEmailVerificationCodeOutput, EmailVerificationOutput, ForgotPasswordOutput, ResetPasswordOutput, - SendEmailVerificationCodeOutput, - SignupInput, - SignupOutput, } from "./auth.types"; import { IUser } from "@modules/user/user.types"; import { sendEmailWithTemplate } from "@services/email.service"; @@ -28,17 +28,11 @@ const AuthService = { signup: async ( input: SignupInput, ): Promise | IErrorPayload> => { - const { firstName, lastName, email, password } = input; - const existingUser = await UserModel.exists({ email }); + const existingUser = + (await UserService.getUserByEmail(input.email)) !== null; if (existingUser) return { success: false, error: "User already exists" }; - const createdUser = new UserModel({ - firstName, - lastName, - email, - password, - role: "owner", - }); + const createdUser = await UserService.createUser(input); await createdUser.save(); const res = await AuthService.sendVerificationEmail(createdUser); @@ -92,9 +86,7 @@ const AuthService = { code: string, email: string, ): Promise | IErrorPayload> => { - const user = await UserModel.findOne({ - email: email, - }); + const user = await UserService.getUserByEmail(email); if (!user) return { success: false, @@ -245,7 +237,7 @@ const AuthService = { email: string, ): Promise | IErrorPayload> => { try { - const user = await UserModel.findOne({ email }); + const user = await UserService.getUserByEmail(email); if (!user) { return { success: true, @@ -303,7 +295,7 @@ const AuthService = { newPassword: string, ): Promise | IErrorPayload> => { try { - const user = await UserModel.findOne({ email }); + const user = await UserService.getUserByEmail(email); if (!user) return { success: false, diff --git a/src/modules/auth/utils/auth.tokens.ts b/src/modules/auth/utils/auth.tokens.ts index ba90fab..9653606 100644 --- a/src/modules/auth/utils/auth.tokens.ts +++ b/src/modules/auth/utils/auth.tokens.ts @@ -6,9 +6,9 @@ import { hashWithCrypto } from "@utils/encryptors"; import { IUser } from "@modules/user/user.types"; import { AccessPayload } from "../auth.types"; import { DEFAULT_REFRESH_DAYS } from "@config/constants"; -import UserModel from "@modules/user/user.model"; import { convertTimeToMilliseconds } from "@utils/index"; import { RefreshTokenModel } from "../refreshToken.model"; +import UserService from "@modules/user/user.service"; export function verifyAccessToken(token: string): AccessPayload { return jwt.verify(token, JWT_SECRET) as AccessPayload; @@ -75,7 +75,7 @@ export async function rotateRefreshToken( }); throw new Error("Refresh token expired"); } - const user = await UserModel.findById(existing.user); + const user = await UserService.getUserById(existing.user.toString()); if (!user) throw new Error("User not found!"); const rawRefreshToken = generateRandomTokenWithCrypto( Number(process.env.REFRESH_TOKEN_BYTES || 64), diff --git a/src/modules/client/__tests__/integration/client.v1.test.ts b/src/modules/client/__tests__/integration/client.v1.test.ts new file mode 100644 index 0000000..20f0552 --- /dev/null +++ b/src/modules/client/__tests__/integration/client.v1.test.ts @@ -0,0 +1,207 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg, seedUserInOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; + +const { verifiedUserEmail } = TEST_CONSTANTS; + +describe("Client Module Integration Tests", () => { + let ownerToken: string; + let managerToken: string; + let memberToken: string; + let viewerToken: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + + // Seed owner and organization + const { user: owner, organization } = await seedOneUserWithOrg( + { + email: verifiedUserEmail, + isEmailVerified: true, + }, + {}, + "OWNER", + ); + orgId = organization._id.toString(); + ownerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: owner._id.toString(), email: owner.email }), + ); + + // Seed manager + const { user: manager } = await seedUserInOrg( + orgId, + { email: "manager@example.com" }, + "MANAGER", + ); + managerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: manager._id.toString(), email: manager.email }), + ); + + // Seed member + const { user: member } = await seedUserInOrg( + orgId, + { email: "member@example.com" }, + "MEMBER", + ); + memberToken = createSignedAccessTokenCookie( + generateAccessToken({ id: member._id.toString(), email: member.email }), + ); + + // Seed viewer + const { user: viewer } = await seedUserInOrg( + orgId, + { email: "viewer@example.com" }, + "VIEWER", + ); + viewerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: viewer._id.toString(), email: viewer.email }), + ); + }); + + describe("POST /api/v1/clients", () => { + const clientData = { + name: "Acme Corp", + email: "billing@acme.com", + currency: "USD", + orgId: "", // Will be set in tests + }; + + it("should allow OWNER to create a new client", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [ownerToken]) + .send({ ...clientData, orgId }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + + it("should allow MANAGER to create a new client", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [managerToken]) + .send({ ...clientData, orgId }); + + expect(res.status).toBe(201); + }); + + it("should deny MEMBER from creating a client", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [memberToken]) + .send({ ...clientData, orgId }); + + expect(res.status).toBe(403); + }); + + it("should deny VIEWER from creating a client", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [viewerToken]) + .send({ ...clientData, orgId }); + + expect(res.status).toBe(403); + }); + + it("should return 400 if name is missing", async () => { + const res = await request(app) + .post("/api/v1/clients") + .set("Cookie", [ownerToken]) + .send({ orgId }); + + expect(res.status).toBe(400); + }); + }); + + describe("GET /api/v1/clients", () => { + beforeEach(async () => { + const ClientModel = (await import("@modules/client/client.model")) + .default; + await new ClientModel({ name: "Client 1", orgId }).save(); + }); + + it("should allow MEMBER to get all clients for organization", async () => { + const res = await request(app) + .get("/api/v1/clients") + .set("Cookie", [memberToken]); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + }); + + it("should allow VIEWER to get all clients for organization", async () => { + const res = await request(app) + .get("/api/v1/clients") + .set("Cookie", [viewerToken]); + + expect(res.status).toBe(200); + }); + }); + + describe("PATCH /api/v1/clients/:id", () => { + let clientId: string; + + beforeEach(async () => { + const ClientModel = (await import("@modules/client/client.model")) + .default; + const client = await new ClientModel({ + name: "Old Client", + orgId, + }).save(); + clientId = client.id.toString(); + }); + + it("should allow MANAGER to update client info", async () => { + const res = await request(app) + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", [managerToken]) + .send({ name: "New Client" }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe("New Client"); + }); + + it("should deny MEMBER from updating client info", async () => { + const res = await request(app) + .patch(`/api/v1/clients/${clientId}`) + .set("Cookie", [memberToken]) + .send({ name: "Unauthorized Update" }); + + expect(res.status).toBe(403); + }); + }); + + describe("DELETE /api/v1/clients/:id", () => { + let clientId: string; + + beforeEach(async () => { + const ClientModel = (await import("@modules/client/client.model")) + .default; + const client = await new ClientModel({ name: "Delete Me", orgId }).save(); + clientId = client.id.toString(); + }); + + it("should allow OWNER to delete a client", async () => { + const res = await request(app) + .delete(`/api/v1/clients/${clientId}`) + .set("Cookie", [ownerToken]); + + expect(res.status).toBe(200); + }); + + it("should deny MEMBER from deleting a client", async () => { + const res = await request(app) + .delete(`/api/v1/clients/${clientId}`) + .set("Cookie", [memberToken]); + + expect(res.status).toBe(403); + }); + }); +}); diff --git a/src/modules/client/client.controller.ts b/src/modules/client/client.controller.ts new file mode 100644 index 0000000..b8f0e98 --- /dev/null +++ b/src/modules/client/client.controller.ts @@ -0,0 +1,105 @@ +import { Request, Response } from "express"; +import ClientService from "./client.service"; +import AppError from "@utils/AppError"; +import { IErrorPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createClient = routeTryCatcher( + async (req: Request, res: Response) => { + const { name, email, address, currency, orgId } = req.body; + const userOrg = req.userOrg!; + + if (userOrg._id.toString() !== orgId) { + throw AppError.forbidden( + "You can only create clients for your own organization", + ); + } + + const result = await ClientService.createClient({ + name, + email, + address, + currency, + orgId, + }); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(201).json(result); + }, +); + +export const getClients = routeTryCatcher( + async (req: Request, res: Response) => { + const userOrg = req.userOrg!; + const clients = await ClientService.getClientsByOrgId( + userOrg._id.toString(), + ); + + res.status(200).json({ + success: true, + data: clients, + }); + }, +); + +export const updateClient = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Client ID is required"); + + const client = await ClientService.getClientById(id); + if (!client) throw AppError.notFound("Client not found"); + + if (userOrg._id.toString() !== client.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to update this client", + ); + } + + const result = await ClientService.updateClient(id, req.body); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export const deleteClient = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Client ID is required"); + + const client = await ClientService.getClientById(id); + if (!client) throw AppError.notFound("Client not found"); + + if (userOrg._id.toString() !== client.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to delete this client", + ); + } + + const result = await ClientService.deleteClient(id); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export default { + createClient, + getClients, + updateClient, + deleteClient, +}; diff --git a/src/modules/client/client.docs.ts b/src/modules/client/client.docs.ts new file mode 100644 index 0000000..ca5ab9e --- /dev/null +++ b/src/modules/client/client.docs.ts @@ -0,0 +1,59 @@ +import { Tspec } from "tspec"; +import { + CreateClientInput, + UpdateClientInput, + ClientOutput, +} from "./client.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type ClientApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/clients"; + tags: ["Clients"]; + paths: { + "/": { + post: { + summary: "Create a new client"; + body: CreateClientInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + get: { + summary: "Get all clients for the organization"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a client"; + path: { id: string }; + body: UpdateClientInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a client"; + path: { id: string }; + responses: { + 200: ISuccessPayload<{ message: string }>; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/client/client.model.ts b/src/modules/client/client.model.ts new file mode 100644 index 0000000..30bd1b4 --- /dev/null +++ b/src/modules/client/client.model.ts @@ -0,0 +1,45 @@ +import mongoose, { Schema } from "mongoose"; +import { IClient } from "./client.types"; +import { GLOBAL_STATUS } from "@constants"; + +const clientSchema: Schema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + email: { + type: String, + trim: true, + lowercase: true, + }, + address: { + type: String, + }, + currency: { + type: String, + default: "USD", + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + status: { + type: String, + enum: Object.values(GLOBAL_STATUS), + default: GLOBAL_STATUS.ACTIVE, + }, + }, + { + timestamps: true, + }, +); + +// Ensure name is unique within the same organization +clientSchema.index({ name: 1, orgId: 1 }, { unique: true }); + +const ClientModel = mongoose.model("Client", clientSchema); + +export default ClientModel; diff --git a/src/modules/client/client.service.ts b/src/modules/client/client.service.ts new file mode 100644 index 0000000..a018926 --- /dev/null +++ b/src/modules/client/client.service.ts @@ -0,0 +1,125 @@ +import { + CreateClientInput, + UpdateClientInput, + ClientOutput, + ClientBase, +} from "./client.types"; +import ClientModel from "./client.model"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: ClientBase & Mappable): ClientOutput => { + return { + id: doc._id.toString(), + name: doc.name, + email: doc.email ?? undefined, + address: doc.address ?? undefined, + currency: doc.currency, + orgId: doc.orgId.toString(), + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const ClientService = { + createClient: async ( + input: CreateClientInput, + ): Promise | IErrorPayload> => { + try { + const existingClient = await ClientModel.findOne({ + name: input.name, + orgId: input.orgId, + }); + + if (existingClient) { + return { + success: false, + error: "Client with this name already exists in the organization", + }; + } + + const client = new ClientModel(input); + await client.save(); + + return { + success: true, + data: mapToOutput(client as unknown as ClientBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getClientsByOrgId: async (orgId: string): Promise => { + const clients = await ClientModel.find({ orgId, status: "ACTIVE" }); + return clients.map((doc) => + mapToOutput(doc as unknown as ClientBase & Mappable), + ); + }, + + getClientById: async (id: string): Promise => { + const client = await ClientModel.findById(id); + return client + ? mapToOutput(client as unknown as ClientBase & Mappable) + : null; + }, + + updateClient: async ( + id: string, + input: UpdateClientInput, + ): Promise | IErrorPayload> => { + try { + const client = await ClientModel.findById(id); + if (!client) { + return { + success: false, + error: "Client not found", + }; + } + + Object.assign(client, input); + await client.save(); + + return { + success: true, + data: mapToOutput(client as unknown as ClientBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + deleteClient: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const client = await ClientModel.findById(id); + if (!client) { + return { + success: false, + error: "Client not found", + }; + } + + await ClientModel.findByIdAndDelete(id); + + return { + success: true, + data: { message: "Client deleted successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default ClientService; diff --git a/src/modules/client/client.types.ts b/src/modules/client/client.types.ts new file mode 100644 index 0000000..0391923 --- /dev/null +++ b/src/modules/client/client.types.ts @@ -0,0 +1,44 @@ +import mongoose from "mongoose"; +import { GlobalStatus } from "@constants"; + +export interface ClientBase { + name: string; + email?: string | undefined; + address?: string | undefined; + currency: string; + orgId: mongoose.Types.ObjectId; + status: GlobalStatus; +} + +export interface IClient extends ClientBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface ClientOutput { + id: string; + name: string; + email?: string | undefined; + address?: string | undefined; + currency: string; + orgId: string; + status: GlobalStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateClientInput { + name: string; + email?: string; + address?: string; + currency?: string; + orgId: string; +} + +export interface UpdateClientInput { + name?: string; + email?: string; + address?: string; + currency?: string; + status?: GlobalStatus; +} diff --git a/src/modules/client/client.validators.ts b/src/modules/client/client.validators.ts new file mode 100644 index 0000000..8e4f4fc --- /dev/null +++ b/src/modules/client/client.validators.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { GLOBAL_STATUS } from "@constants"; +import { zObjectId } from "@utils/validators"; + +export const createClientSchema = z.object({ + name: z.string().min(1).max(100), + email: z.string().email().optional(), + address: z.string().max(500).optional(), + currency: z.string().min(1).max(10).optional(), + orgId: zObjectId, +}); + +export const updateClientSchema = z.object({ + name: z.string().min(1).max(100).optional(), + email: z.string().email().optional(), + address: z.string().max(500).optional(), + currency: z.string().min(1).max(10).optional(), + status: z + .enum(Object.values(GLOBAL_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/client/routes/client.v1.routes.ts b/src/modules/client/routes/client.v1.routes.ts new file mode 100644 index 0000000..d9310bd --- /dev/null +++ b/src/modules/client/routes/client.v1.routes.ts @@ -0,0 +1,41 @@ +import { Router } from "express"; +import ClientController from "../client.controller"; +import authenticate from "@middlewares/authenticate"; +import validate from "@middlewares/validators"; +import { createClientSchema, updateClientSchema } from "../client.validators"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +const clientRouter = Router(); + +clientRouter.use(authenticate); + +clientRouter.post( + "/", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(createClientSchema), + ClientController.createClient, +); +clientRouter.get( + "/", + requireRole([ + USER_ROLES.OWNER, + USER_ROLES.MANAGER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, + ]), + ClientController.getClients, +); +clientRouter.patch( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(updateClientSchema), + ClientController.updateClient, +); +clientRouter.delete( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + ClientController.deleteClient, +); + +export default clientRouter; diff --git a/src/modules/membership/membership.controller.ts b/src/modules/membership/membership.controller.ts new file mode 100644 index 0000000..7bc722c --- /dev/null +++ b/src/modules/membership/membership.controller.ts @@ -0,0 +1,30 @@ +import { Request, Response, NextFunction } from "express"; +import MembershipService from "./membership.service"; +import { + CreateMembershipInput, + CreateMembershipOutput, +} from "./membership.types"; +import AppError from "@utils/AppError"; +import { IErrorPayload, ISuccessPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createMembership = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const input: CreateMembershipInput = req.body; + + const result = await MembershipService.createMembership(input); + + if ((result as IErrorPayload).error) + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Membership creation failed", + ), + ); + + return res.status(201).json({ + success: true, + message: "Membership created successfully", + data: (result as ISuccessPayload).data, + }); + }, +); diff --git a/src/modules/membership/membership.docs.ts b/src/modules/membership/membership.docs.ts new file mode 100644 index 0000000..e4bb5f5 --- /dev/null +++ b/src/modules/membership/membership.docs.ts @@ -0,0 +1,24 @@ +import { Tspec } from "tspec"; +import { + CreateMembershipInput, + CreateMembershipOutput, +} from "./membership.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type MembershipApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/membership"; + tags: ["Membership"]; + paths: { + "/": { + post: { + summary: "Create a new membership"; + body: CreateMembershipInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/membership/membership.model.ts b/src/modules/membership/membership.model.ts new file mode 100644 index 0000000..60e2db6 --- /dev/null +++ b/src/modules/membership/membership.model.ts @@ -0,0 +1,66 @@ +import mongoose, { Schema } from "mongoose"; +import { IMembership } from "./membership.types"; +import { USER_ROLES, MEMBERSHIP_STATUS } from "@constants"; + +const membershipSchema = new Schema( + { + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: function (this: IMembership) { + return this.status !== "PENDING"; + }, + }, + email: { + type: String, + required: function (this: IMembership) { + return this.status === "PENDING" && !this.userId; + }, + }, + role: { + type: String, + enum: Object.values(USER_ROLES), + required: true, + }, + status: { + type: String, + enum: Object.values(MEMBERSHIP_STATUS), + default: MEMBERSHIP_STATUS.ACTIVE, + }, + inviteTokenHash: { + type: String, + default: null, + select: false, + }, + inviteExpiresAt: { + type: Date, + default: null, + }, + acceptedAt: { + type: Date, + default: null, + }, + joinedAt: { + type: Date, + default: null, + }, + invitedBy: { + type: Schema.Types.ObjectId, + ref: "User", + default: null, + }, + }, + { timestamps: true }, +); + +const MembershipModel = mongoose.model( + "Membership", + membershipSchema, +); + +export default MembershipModel; diff --git a/src/modules/membership/membership.service.ts b/src/modules/membership/membership.service.ts new file mode 100644 index 0000000..e4e5e9a --- /dev/null +++ b/src/modules/membership/membership.service.ts @@ -0,0 +1,114 @@ +import mongoose from "mongoose"; +import MembershipModel from "./membership.model"; +import { + CreateMembershipInput, + CreateMembershipOutput, + IMembership, +} from "./membership.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +const MembershipService = { + createMembership: async ( + input: CreateMembershipInput, + session?: mongoose.ClientSession, + ): Promise | IErrorPayload> => { + try { + const membershipData: { + orgId: string; + userId?: string; + email?: string; + role: string; + status: string; + inviteTokenHash?: string; + inviteExpiresAt?: Date; + invitedBy?: string; + } = { + orgId: input.orgId, + role: input.role, + status: input.status || "ACTIVE", + }; + + if (input.userId) { + membershipData.userId = input.userId; + } else if (input.email) { + membershipData.email = input.email; + } + + if (input.inviteTokenHash) { + membershipData.inviteTokenHash = input.inviteTokenHash; + } + if (input.inviteExpiresAt) { + membershipData.inviteExpiresAt = input.inviteExpiresAt; + } + if (input.invitedBy) { + membershipData.invitedBy = input.invitedBy; + } + + const membership = new MembershipModel(membershipData); + + await membership.save({ session: session || null }); + + return { + success: true, + data: { + membershipId: membership._id.toString(), + }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getPendingMembershipByHash: async ( + input: string, + session?: mongoose.ClientSession | null, + ): Promise => { + return await MembershipModel.findOne({ + inviteTokenHash: input, + status: "PENDING", + }).session(session || null); + }, + + getMembershipById: async (id: string): Promise => { + return await MembershipModel.findById(id); + }, + + getMembershipsByUser: async (userId: string): Promise => { + return await MembershipModel.find({ + userId: new mongoose.Types.ObjectId(userId), + }); + }, + + getMembershipByUserAndOrg: async ( + userId: string, + orgId: string, + options?: { select: string }, + ): Promise => { + return MembershipModel.findOne({ + userId: new mongoose.Types.ObjectId(userId), + orgId: new mongoose.Types.ObjectId(orgId), + }).select(options?.select || ""); + }, + + getMembershipsByOrg: async (orgId: string): Promise => { + return await MembershipModel.find({ + orgId: new mongoose.Types.ObjectId(orgId), + }) + .populate("userId", "firstName lastName email") + .sort({ createdAt: 1 }); + }, + getMembershipByEmailAndOrg: async ( + email: string, + orgId: string, + ): Promise => { + return await MembershipModel.findOne({ + email, + orgId: new mongoose.Types.ObjectId(orgId), + }); + }, +}; + +export default MembershipService; diff --git a/src/modules/membership/membership.types.ts b/src/modules/membership/membership.types.ts new file mode 100644 index 0000000..36a5720 --- /dev/null +++ b/src/modules/membership/membership.types.ts @@ -0,0 +1,44 @@ +import mongoose from "mongoose"; +import { UserRole, MembershipStatus } from "@constants"; + +export interface IMembership extends mongoose.Document { + _id: mongoose.Types.ObjectId; + orgId: mongoose.Types.ObjectId; + userId?: mongoose.Types.ObjectId | null; + email?: string | null; + role: UserRole; + status: MembershipStatus; + inviteTokenHash?: string | null; + inviteExpiresAt?: Date | null; + acceptedAt?: Date | null; + joinedAt?: Date | null; + invitedBy?: mongoose.Types.ObjectId | null; + createdAt: Date; + updatedAt: Date; +} + +export type CreateMembershipInput = { + orgId: string; + userId?: string; + email?: string; + role: UserRole; + status?: MembershipStatus; + inviteTokenHash?: string; + inviteExpiresAt?: Date; + invitedBy?: string; +}; + +export type CreateMembershipOutput = { + membershipId: string; +}; + +export type MembershipData = { + orgId: string; + userId?: string; + email?: string; + role: string; + status: string; + inviteTokenHash?: string; + inviteExpiresAt?: Date; + invitedBy?: string; +}; diff --git a/src/modules/membership/membership.validators.ts b/src/modules/membership/membership.validators.ts new file mode 100644 index 0000000..42f4da5 --- /dev/null +++ b/src/modules/membership/membership.validators.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { USER_ROLES, MEMBERSHIP_STATUS } from "@constants"; + +export const createMembershipSchema = z.object({ + orgId: z.string().min(1, "Organization ID is required"), + userId: z.string().min(1, "User ID is required"), + role: z.enum(Object.values(USER_ROLES) as [string, ...string[]], { + errorMap: () => ({ message: "Invalid role" }), + }), + status: z + .enum(Object.values(MEMBERSHIP_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/membership/routes/membership.v1.routes.ts b/src/modules/membership/routes/membership.v1.routes.ts new file mode 100644 index 0000000..461ea89 --- /dev/null +++ b/src/modules/membership/routes/membership.v1.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import validateResource from "@middlewares/validators"; +import authenticate from "@middlewares/authenticate"; +import { createMembershipSchema } from "../membership.validators"; +import { createMembership } from "../membership.controller"; + +const membershipRouter = Router(); + +membershipRouter.post( + "/", + authenticate, + validateResource(createMembershipSchema), + createMembership, +); + +export default membershipRouter; diff --git a/src/modules/organization/__tests__/integration/acceptInvite.v1.test.ts b/src/modules/organization/__tests__/integration/acceptInvite.v1.test.ts new file mode 100644 index 0000000..8f70310 --- /dev/null +++ b/src/modules/organization/__tests__/integration/acceptInvite.v1.test.ts @@ -0,0 +1,187 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserService from "@modules/user/user.service"; +import OrganizationService from "@modules/organization/organization.service"; +import MembershipService from "@modules/membership/membership.service"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "@modules/auth/__tests__/helpers/testHelpers"; +import { UserFactory } from "@tests/factories/user.factory"; +import { IUser } from "@modules/user/user.types"; +import { hashWithCrypto } from "@utils/encryptors"; +import { CreateOrganizationOutput } from "@modules/organization/organization.types"; +import { ISuccessPayload } from "src/types"; + +const { testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); + jest.clearAllMocks(); +}); + +describe("POST /api/v1/org/invite/accept", () => { + let owner: IUser; + let organizationId: string; + let invitee: IUser; + let cookie: string; + let rawToken: string; + let inviteTokenHash: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate(); + owner = await UserService.createUser({ + ...ownerData, + password: testPassword, + }); + owner.isEmailVerified = true; + await owner.save(); + + const orgResult = await OrganizationService.createOrganization(owner, { + name: "Test Org", + size: 10, + }); + if (!orgResult.success) throw new Error("Failed to create org"); + organizationId = (orgResult as ISuccessPayload) + .data.organizationId; + + const inviteeData = UserFactory.generate(); + invitee = await UserService.createUser({ + ...inviteeData, + password: testPassword, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const accessToken = generateAccessToken({ + id: invitee._id.toString(), + email: invitee.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + + rawToken = "valid-token-123"; + inviteTokenHash = hashWithCrypto(rawToken); + + await MembershipService.createMembership({ + orgId: organizationId, + email: invitee.email, + role: "MEMBER", + status: "PENDING", + inviteTokenHash: inviteTokenHash, + inviteExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + invitedBy: owner._id.toString(), + }); + }); + + it("should successfully accept an invitation", async () => { + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [cookie]) + .send({ token: rawToken }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty("membershipId"); + expect(res.body.data.orgId.toString()).toBe(organizationId); + + const membership = await MembershipService.getMembershipByUserAndOrg( + invitee._id.toString(), + organizationId, + { select: "+inviteTokenHash" }, + ); + + expect(membership).toBeDefined(); + expect(membership?.status).toBe("ACTIVE"); + expect(membership?.userId?.toString()).toBe(invitee._id.toString()); + expect(membership?.inviteTokenHash).toBeNull(); + expect(membership?.inviteExpiresAt).toBeNull(); + expect(membership?.acceptedAt).toBeDefined(); + expect(membership?.joinedAt).toBeDefined(); + }); + + it("should return 404 if token is invalid", async () => { + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [cookie]) + .send({ token: "invalid-token" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Invite not found"); + }); + + it("should return 410 if invite is expired", async () => { + const existing = await MembershipService.getMembershipByEmailAndOrg( + invitee.email, + organizationId, + ); + if (existing) { + existing.inviteExpiresAt = new Date(Date.now() - 10000); + await existing.save(); + } + + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [cookie]) + .send({ token: rawToken }); + + expect(res.status).toBe(410); + expect(res.body.error).toBe("Invite expired"); + }); + + it("should return 403 if email validation fails (mismatch)", async () => { + const otherUser = await UserService.createUser({ + ...UserFactory.generate(), + password: testPassword, + }); + const otherToken = generateAccessToken({ + id: otherUser._id.toString(), + email: otherUser.email, + }); + const otherCookie = createSignedAccessTokenCookie(otherToken); + + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [otherCookie]) + .send({ token: rawToken }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Invite email mismatch"); + }); + + it("should return 409 if user already has an active organization membership", async () => { + const newOwner = await UserService.createUser({ + ...UserFactory.generate(), + password: testPassword, + }); + newOwner.isEmailVerified = true; + await newOwner.save(); + + const otherOrgResult = await OrganizationService.createOrganization( + newOwner, + { + name: "Other Org", + size: 5, + }, + ); + const otherOrgId = ( + otherOrgResult as ISuccessPayload + ).data.organizationId; + + await MembershipService.createMembership({ + orgId: otherOrgId, + userId: invitee._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const res = await request(app) + .post("/api/v1/org/invite/accept") + .set("Cookie", [cookie]) + .send({ token: rawToken }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("User already belongs to an organization"); + }); +}); diff --git a/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts b/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts new file mode 100644 index 0000000..2f4a272 --- /dev/null +++ b/src/modules/organization/__tests__/integration/createOrganization.v1.test.ts @@ -0,0 +1,391 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserService from "@modules/user/user.service"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "@modules/auth/__tests__/helpers/testHelpers"; +import { UserFactory } from "@tests/factories/user.factory"; +import { IUser } from "@modules/user/user.types"; +import OrganizationService from "@modules/organization/organization.service"; +import MembershipService from "@modules/membership/membership.service"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +describe("POST /api/v1/org", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).post("/api/v1/org").send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Authentication required"); + }); + + it("should return 401 if access token is invalid", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", ["access_token=invalid_token"]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("Validation", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 400 if name is missing", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + size: 10, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if name is too short", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "A", + size: 10, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect( + res.body.error?.toLowerCase().includes("at least 2 characters") || + JSON.stringify(res.body) + .toLowerCase() + .includes("at least 2 characters"), + ).toBe(true); + }); + + it("should return 400 if name is too long", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "A".repeat(101), + size: 10, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect( + res.body.error + ?.toLowerCase() + .includes("must not exceed 100 characters") || + JSON.stringify(res.body) + .toLowerCase() + .includes("must not exceed 100 characters"), + ).toBe(true); + }); + + it("should return 400 if size is missing", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if size is less than 1", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 0, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + // Validation errors may be in error field or nested in errors + expect( + res.body.error?.toLowerCase().includes("at least 1") || + JSON.stringify(res.body).toLowerCase().includes("at least 1"), + ).toBe(true); + }); + + it("should return 400 if size is not an integer", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10.5, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should accept valid optional fields (domain, description)", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + domain: "example.com", + description: "Test description", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + }); + + describe("Successful Organization Creation", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 201 and create organization with membership", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Organization created successfully"); + expect(res.body.data).toHaveProperty("organizationId"); + expect(res.body.data).toHaveProperty("membershipId"); + }); + + it("should create organization in database with correct fields", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + domain: "example.com", + description: "Test description", + }); + + expect(res.status).toBe(201); + + const organization = await OrganizationService.getOrganizationById( + res.body.data.organizationId, + ); + + expect(organization).toBeDefined(); + expect(organization?.name).toBe("Test Organization"); + expect(organization?.size).toBe(10); + expect(organization?.domain).toBe("example.com"); + expect(organization?.description).toBe("Test description"); + expect(organization?.owner.toString()).toBe(user._id.toString()); + expect(organization?.status).toBe("ACTIVE"); + expect(organization?.slug).toBeDefined(); + expect(organization?.settings.timezone).toBe("UTC"); + expect(organization?.settings.workHours).toBe(8); + }); + + it("should create OWNER membership with ACTIVE status", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(201); + + const membership = await MembershipService.getMembershipById( + res.body.data.membershipId, + ); + + expect(membership).toBeDefined(); + expect(membership?.role).toBe("OWNER"); + expect(membership?.status).toBe("ACTIVE"); + expect(membership?.orgId.toString()).toBe(res.body.data.organizationId); + expect(membership?.userId?.toString()).toBe(user._id.toString()); + }); + + it("should generate slug for organization", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Test Organization", + size: 10, + }); + + expect(res.status).toBe(201); + + const org = await OrganizationService.getOrganizationById( + res.body.data.organizationId, + ); + const slug = org?.slug; + + expect(slug).toBeDefined(); + expect(slug).toBe("test-organization"); + }); + + it("should return 409 if user already has an organization", async () => { + const res1 = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "First Organization", + size: 10, + }); + + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Second Organization", + size: 20, + }); + + expect(res2.status).toBe(409); + expect(res2.body.success).toBe(false); + expect(res2.body.error).toBe("User already has an organization"); + }); + }); + + describe("Transaction Safety", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should handle transaction rollback correctly", async () => { + const res = await request(app) + .post("/api/v1/org") + .set("Cookie", [cookie]) + .send({ + name: "Transaction Test Organization", + size: 10, + }); + + expect(res.status).toBe(201); + const organization = await OrganizationService.getOrganizationById( + res.body.data.organizationId, + ); + const membership = await MembershipService.getMembershipById( + res.body.data.membershipId, + ); + + expect(organization).toBeDefined(); + expect(membership).toBeDefined(); + expect(membership?.orgId.toString()).toBe(organization?._id.toString()); + expect(membership?.userId?.toString()).toBe(user._id.toString()); + }); + }); +}); diff --git a/src/modules/organization/__tests__/integration/getOrganization.v1.test.ts b/src/modules/organization/__tests__/integration/getOrganization.v1.test.ts new file mode 100644 index 0000000..8ebc5d6 --- /dev/null +++ b/src/modules/organization/__tests__/integration/getOrganization.v1.test.ts @@ -0,0 +1,219 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserService from "@modules/user/user.service"; +import OrganizationService from "@modules/organization/organization.service"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "@modules/auth/__tests__/helpers/testHelpers"; +import { UserFactory } from "@tests/factories/user.factory"; +import { IUser } from "@modules/user/user.types"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +describe("GET /api/v1/org", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).get("/api/v1/org"); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Authentication required"); + }); + + it("should return 401 if access token is invalid", async () => { + const res = await request(app) + .get("/api/v1/org") + .set("Cookie", ["access_token=invalid_token"]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("User Has No Organization", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 404 if user does not have an organization", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User does not have an organization"); + }); + }); + + describe("Successful Organization Retrieval", () => { + let user: IUser; + let organizationId: string; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const createOrgResult = await OrganizationService.createOrganization( + user, + { + name: "Test Organization", + size: 10, + domain: "example.com", + description: "Test description", + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 200 and organization with user role", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Organization retrieved successfully"); + expect(res.body.data).toHaveProperty("organization"); + expect(res.body.data).toHaveProperty("role"); + }); + + it("should return correct organization data structure", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const { organization } = res.body.data; + + expect(organization.id).toBe(organizationId); + expect(organization.name).toBe("Test Organization"); + expect(organization.slug).toBeDefined(); + expect(organization.domain).toBe("example.com"); + expect(organization.description).toBe("Test description"); + expect(organization.status).toBe("ACTIVE"); + expect(organization.size).toBe(10); + expect(organization.settings.timezone).toBe("UTC"); + expect(organization.settings.workHours).toBe(8); + expect(organization.createdAt).toBeDefined(); + expect(organization.updatedAt).toBeDefined(); + }); + + it("should return OWNER role for organization creator", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.data.role).toBe("OWNER"); + }); + }); + + describe("Organization Without Optional Fields", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const createOrgResult = await OrganizationService.createOrganization( + user, + { + name: "Minimal Organization", + size: 5, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should handle organization without optional fields", async () => { + const res = await request(app).get("/api/v1/org").set("Cookie", [cookie]); + + expect(res.status).toBe(200); + const { organization: orgData } = res.body.data; + expect(orgData.name).toBe("Minimal Organization"); + expect(orgData.domain).toBeUndefined(); + expect(orgData.description).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts b/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts new file mode 100644 index 0000000..4193cc5 --- /dev/null +++ b/src/modules/organization/__tests__/integration/getOrganizationMembers.v1.test.ts @@ -0,0 +1,427 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserService from "@modules/user/user.service"; +import OrganizationService from "@modules/organization/organization.service"; +import MembershipService from "@modules/membership/membership.service"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "@modules/auth/__tests__/helpers/testHelpers"; +import { UserFactory } from "@tests/factories/user.factory"; +import { IUser } from "@modules/user/user.types"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); +}); + +describe("GET /api/v1/org/members", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).get("/api/v1/org/members"); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Authentication required"); + }); + + it("should return 401 if access token is invalid", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", ["access_token=invalid_token"]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("Authorization", () => { + let owner: IUser; + let admin: IUser; + let member: IUser; + let viewer: IUser; + let organizationId: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const adminData = UserFactory.generate({ + email: "admin@example.com", + password: testPassword, + }); + admin = await UserService.createUser({ + firstName: adminData.firstName, + lastName: adminData.lastName, + email: adminData.email, + password: adminData.password, + }); + admin.isEmailVerified = true; + await admin.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: admin._id.toString(), + role: "MANAGER", + status: "ACTIVE", + }); + const memberData = UserFactory.generate({ + email: "member@example.com", + password: testPassword, + }); + member = await UserService.createUser({ + firstName: memberData.firstName, + lastName: memberData.lastName, + email: memberData.email, + password: memberData.password, + }); + member.isEmailVerified = true; + await member.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: member._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const viewerData = UserFactory.generate({ + email: "viewer@example.com", + password: testPassword, + }); + viewer = await UserService.createUser({ + firstName: viewerData.firstName, + lastName: viewerData.lastName, + email: viewerData.email, + password: viewerData.password, + }); + viewer.isEmailVerified = true; + await viewer.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: viewer._id.toString(), + role: "VIEWER", + status: "ACTIVE", + }); + }); + + it("should return 403 if user is MEMBER", async () => { + const accessToken = generateAccessToken({ + id: member._id.toString(), + email: member.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Access denied"); + }); + + it("should return 403 if user is VIEWER", async () => { + const accessToken = generateAccessToken({ + id: viewer._id.toString(), + email: viewer.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Access denied"); + }); + + it("should return 200 if user is OWNER", async () => { + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("should return 200 if user is MANAGER", async () => { + const accessToken = generateAccessToken({ + id: admin._id.toString(), + email: admin.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + describe("User Has No Organization", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 404 if user does not have an organization", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User does not have an organization"); + }); + }); + + describe("Successful Member Retrieval", () => { + let owner: IUser; + let admin: IUser; + let member: IUser; + let organizationId: string; + let cookie: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const adminData = UserFactory.generate({ + email: "admin@example.com", + password: testPassword, + }); + admin = await UserService.createUser({ + firstName: adminData.firstName, + lastName: adminData.lastName, + email: adminData.email, + password: adminData.password, + }); + admin.isEmailVerified = true; + await admin.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: admin._id.toString(), + role: "MANAGER", + status: "ACTIVE", + }); + + const memberData = UserFactory.generate({ + email: "member@example.com", + password: testPassword, + }); + member = await UserService.createUser({ + firstName: memberData.firstName, + lastName: memberData.lastName, + email: memberData.email, + password: memberData.password, + }); + member.isEmailVerified = true; + await member.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: member._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 200 and list of members", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe( + "Organization members retrieved successfully", + ); + expect(res.body.data).toHaveProperty("members"); + expect(Array.isArray(res.body.data.members)).toBe(true); + }); + + it("should return all members of the organization", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + expect(res.body.data.members).toHaveLength(3); + + const memberEmails = res.body.data.members.map( + (m: { email: string }) => m.email, + ); + expect(memberEmails).toContain(owner.email); + expect(memberEmails).toContain(admin.email); + expect(memberEmails).toContain(member.email); + }); + + it("should return correct member data structure", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const firstMember = res.body.data.members[0]; + expect(firstMember).toHaveProperty("membershipId"); + expect(firstMember).toHaveProperty("userId"); + expect(firstMember).toHaveProperty("firstName"); + expect(firstMember).toHaveProperty("lastName"); + expect(firstMember).toHaveProperty("email"); + expect(firstMember).toHaveProperty("role"); + expect(firstMember).toHaveProperty("status"); + expect(firstMember).toHaveProperty("joinedAt"); + }); + + it("should return members sorted by creation date", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const members = res.body.data.members; + expect(members.length).toBeGreaterThan(1); + + for (let i = 1; i < members.length; i++) { + const prevDate = new Date(members[i - 1].joinedAt).getTime(); + const currDate = new Date(members[i].joinedAt).getTime(); + expect(currDate).toBeGreaterThanOrEqual(prevDate); + } + }); + + it("should include correct roles for each member", async () => { + const res = await request(app) + .get("/api/v1/org/members") + .set("Cookie", [cookie]); + + expect(res.status).toBe(200); + + const members = res.body.data.members; + const ownerMember = members.find( + (m: { email: string }) => m.email === owner.email, + ); + const adminMember = members.find( + (m: { email: string }) => m.email === admin.email, + ); + const memberMember = members.find( + (m: { email: string }) => m.email === member.email, + ); + + expect(ownerMember.role).toBe("OWNER"); + expect(adminMember.role).toBe("MANAGER"); + expect(memberMember.role).toBe("MEMBER"); + }); + }); +}); diff --git a/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts b/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts new file mode 100644 index 0000000..6b4f230 --- /dev/null +++ b/src/modules/organization/__tests__/integration/inviteMember.v1.test.ts @@ -0,0 +1,763 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import UserService from "@modules/user/user.service"; +import OrganizationService from "@modules/organization/organization.service"; +import MembershipService from "@modules/membership/membership.service"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "@modules/auth/__tests__/helpers/testHelpers"; +import { UserFactory } from "@tests/factories/user.factory"; +import { IUser } from "@modules/user/user.types"; +import { sendEmailWithTemplate } from "@services/email.service"; + +jest.mock("@services/email.service"); + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +beforeEach(async () => { + await clearDB(); + jest.clearAllMocks(); + (sendEmailWithTemplate as jest.Mock).mockResolvedValue({ + success: true, + emailSent: true, + }); +}); + +describe("POST /api/v1/org/invite", () => { + describe("Authentication", () => { + it("should return 401 if access token cookie is missing", async () => { + const res = await request(app).post("/api/v1/org/invite").send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("Authentication required"); + }); + + it("should return 401 if access token is invalid", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", ["access_token=invalid_token"]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("should return 401 if user in token does not exist", async () => { + const nonExistentUserId = "507f1f77bcf86cd799439011"; + const accessToken = generateAccessToken({ + id: nonExistentUserId, + email: "nonexistent@example.com", + }); + + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User not found"); + }); + }); + + describe("Authorization", () => { + let owner: IUser; + let admin: IUser; + let member: IUser; + let viewer: IUser; + let organizationId: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const adminData = UserFactory.generate({ + email: "admin@example.com", + password: testPassword, + }); + admin = await UserService.createUser({ + firstName: adminData.firstName, + lastName: adminData.lastName, + email: adminData.email, + password: adminData.password, + }); + admin.isEmailVerified = true; + await admin.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: admin._id.toString(), + role: "MANAGER", + status: "ACTIVE", + }); + + const memberData = UserFactory.generate({ + email: "member@example.com", + password: testPassword, + }); + member = await UserService.createUser({ + firstName: memberData.firstName, + lastName: memberData.lastName, + email: memberData.email, + password: memberData.password, + }); + member.isEmailVerified = true; + await member.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: member._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const viewerData = UserFactory.generate({ + email: "viewer@example.com", + password: testPassword, + }); + viewer = await UserService.createUser({ + firstName: viewerData.firstName, + lastName: viewerData.lastName, + email: viewerData.email, + password: viewerData.password, + }); + viewer.isEmailVerified = true; + await viewer.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: viewer._id.toString(), + role: "VIEWER", + status: "ACTIVE", + }); + }); + + it("should return 403 if user is MEMBER", async () => { + const accessToken = generateAccessToken({ + id: member._id.toString(), + email: member.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Access denied"); + }); + + it("should return 403 if user is VIEWER", async () => { + const accessToken = generateAccessToken({ + id: viewer._id.toString(), + email: viewer.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Access denied"); + }); + + it("should allow OWNER to invite", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + + it("should allow MANAGER to invite", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const accessToken = generateAccessToken({ + id: admin._id.toString(), + email: admin.email, + }); + const cookie = createSignedAccessTokenCookie(accessToken); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + }); + + describe("Validation", () => { + let owner: IUser; + let cookie: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + await OrganizationService.createOrganization(owner, { + name: "Test Organization", + size: 10, + }); + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 400 if email is missing", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + role: "MEMBER", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if email is invalid", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invalid-email", + role: "MEMBER", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if role is missing", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("should return 400 if role is invalid", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "INVALID_ROLE", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe("User Has No Organization", () => { + let user: IUser; + let cookie: string; + + beforeEach(async () => { + const userData = UserFactory.generate({ + email: verifiedUserEmail, + password: testPassword, + }); + user = await UserService.createUser({ + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + password: userData.password, + }); + user.isEmailVerified = true; + await user.save(); + + const accessToken = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 404 if user does not have an organization", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("User does not have an organization"); + }); + }); + + describe("Business Logic", () => { + let owner: IUser; + let cookie: string; + let organizationId: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 400 if user is already an active member", async () => { + const existingUserData = UserFactory.generate({ + email: "existing@example.com", + password: testPassword, + }); + const existingUser = await UserService.createUser({ + firstName: existingUserData.firstName, + lastName: existingUserData.lastName, + email: existingUserData.email, + password: existingUserData.password, + }); + existingUser.isEmailVerified = true; + await existingUser.save(); + + await MembershipService.createMembership({ + orgId: organizationId, + userId: existingUser._id.toString(), + role: "MEMBER", + status: "ACTIVE", + }); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "existing@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toBe( + "User is already a member of this organization", + ); + }); + + it("should return 400 if pending invitation already exists", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res1 = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res2.status).toBe(400); + expect(res2.body.success).toBe(false); + expect(res2.body.error).toBe( + "An invitation has already been sent to this email", + ); + }); + + it("should allow inviting non-existent users", async () => { + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "nonexistent@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + + const membership = await MembershipService.getMembershipByEmailAndOrg( + "nonexistent@example.com", + organizationId, + ); + + expect(membership).toBeDefined(); + expect(membership?.email).toBe("nonexistent@example.com"); + expect(membership?.userId).toBeUndefined(); + expect(membership?.status).toBe("PENDING"); + }); + }); + + describe("Successful Invitation", () => { + let owner: IUser; + let cookie: string; + let organizationId: string; + + beforeEach(async () => { + const ownerData = UserFactory.generate({ + email: "owner@example.com", + password: testPassword, + }); + owner = await UserService.createUser({ + firstName: ownerData.firstName, + lastName: ownerData.lastName, + email: ownerData.email, + password: ownerData.password, + }); + owner.isEmailVerified = true; + await owner.save(); + + const createOrgResult = await OrganizationService.createOrganization( + owner, + { + name: "Test Organization", + size: 10, + }, + ); + + if (!createOrgResult.success) { + throw new Error("Failed to create organization"); + } + + organizationId = ( + createOrgResult as { success: true; data: { organizationId: string } } + ).data.organizationId; + + const accessToken = generateAccessToken({ + id: owner._id.toString(), + email: owner.email, + }); + cookie = createSignedAccessTokenCookie(accessToken); + }); + + it("should return 201 and create pending membership", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Invitation sent successfully"); + expect(res.body.data).toHaveProperty("invitationId"); + expect(res.body.data).toHaveProperty("emailSent"); + expect(res.body.data.emailSent).toBe(true); + }); + + it("should create pending membership in database", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MANAGER", + }); + + expect(res.status).toBe(201); + + const membership = await MembershipService.getMembershipByUserAndOrg( + invitee._id.toString(), + organizationId, + ); + + expect(membership).toBeDefined(); + expect(membership?.role).toBe("MANAGER"); + expect(membership?.status).toBe("PENDING"); + expect(membership?.invitedBy?.toString()).toBe(owner._id.toString()); + }); + + it("should send email with correct merge info", async () => { + const inviteeData = UserFactory.generate({ + email: "invitee@example.com", + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "invitee@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(sendEmailWithTemplate).toHaveBeenCalledTimes(1); + + const call = (sendEmailWithTemplate as jest.Mock).mock.calls[0][0]; + expect(call.to[0].email_address.address).toBe("invitee@example.com"); + expect(call.merge_info.role).toBe("MEMBER"); + expect(call.merge_info.organizationName).toBe("Test Organization"); + expect(call.merge_info.name).toBe("invitee"); + expect(call.merge_info.acceptLink).toContain("/accept-invitation?token="); + expect(call.merge_info.verify_account_link).toBeDefined(); + expect(call.merge_info.invitationLinkExpiry).toBe("7 days"); + expect(call.merge_info.ownersName).toBeDefined(); + }); + + it("should work for different roles", async () => { + const roles = ["OWNER", "MANAGER", "MEMBER", "VIEWER"] as const; + + for (const role of roles) { + const inviteeData = UserFactory.generate({ + email: `invitee-${role.toLowerCase()}@example.com`, + password: testPassword, + }); + const invitee = await UserService.createUser({ + firstName: inviteeData.firstName, + lastName: inviteeData.lastName, + email: inviteeData.email, + password: inviteeData.password, + }); + invitee.isEmailVerified = true; + await invitee.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: `invitee-${role.toLowerCase()}@example.com`, + role, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + } + }); + + it("should create pending membership for existing users who are not members", async () => { + const existingUserData = UserFactory.generate({ + email: "existing@example.com", + password: testPassword, + }); + const existingUser = await UserService.createUser({ + firstName: existingUserData.firstName, + lastName: existingUserData.lastName, + email: existingUserData.email, + password: existingUserData.password, + }); + existingUser.isEmailVerified = true; + await existingUser.save(); + + const res = await request(app) + .post("/api/v1/org/invite") + .set("Cookie", [cookie]) + .send({ + email: "existing@example.com", + role: "MEMBER", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + + const membership = await MembershipService.getMembershipByUserAndOrg( + existingUser._id.toString(), + organizationId, + ); + expect(membership).toBeDefined(); + expect(membership?.status).toBe("PENDING"); + expect(membership?.invitedBy?.toString()).toBe(owner._id.toString()); + }); + }); +}); diff --git a/src/modules/organization/organization.controller.ts b/src/modules/organization/organization.controller.ts new file mode 100644 index 0000000..1183173 --- /dev/null +++ b/src/modules/organization/organization.controller.ts @@ -0,0 +1,236 @@ +import { Request, Response, NextFunction } from "express"; +import mongoose from "mongoose"; +import OrganizationService from "./organization.service"; +import MembershipService from "@modules/membership/membership.service"; +import { hashWithCrypto } from "@utils/encryptors"; +import { + CreateOrganizationInput, + CreateOrganizationOutput, + GetOrganizationOutput, + GetOrganizationMembersOutput, + InviteMemberOutput, + GetUserOrganizationOutput, +} from "./organization.types"; +import AppError from "@utils/AppError"; +import { IErrorPayload, ISuccessPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; +import { IUser } from "@modules/user/user.types"; +import { IMembership } from "@modules/membership/membership.types"; + +export const createOrganization = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const input: CreateOrganizationInput = req.body; + + const result = await OrganizationService.createOrganization( + req.user as IUser, + input, + ); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "User already has an organization") { + return next(AppError.conflict(error)); + } + return next(AppError.badRequest(error || "Organization creation failed")); + } + + return res.status(201).json({ + success: true, + message: "Organization created successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const getOrganization = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + const result = await OrganizationService.getUserOrganization( + user._id.toString(), + ); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "User does not have an organization") { + return next(AppError.notFound(error)); + } + if (error === "Organization not found") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error)); + } + + const { organization, role } = ( + result as ISuccessPayload + ).data; + + const output: GetOrganizationOutput = { + organization: { + id: organization._id.toString(), + name: organization.name, + slug: organization.slug, + ...(organization.domain && { domain: organization.domain }), + ...(organization.description && { + description: organization.description, + }), + status: organization.status, + size: organization.size, + settings: organization.settings, + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + }, + role, + }; + + return res.status(200).json({ + success: true, + message: "Organization retrieved successfully", + data: output, + }); + }, +); + +export const getOrganizationMembers = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + if (!user) return next(AppError.unauthorized("User not found")); + + const result = await OrganizationService.getOrganizationMembers( + user._id.toString(), + ); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "User does not have an organization") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error)); + } + + const output: GetOrganizationMembersOutput = ( + result as ISuccessPayload + ).data; + + return res.status(200).json({ + success: true, + message: "Organization members retrieved successfully", + data: output, + }); + }, +); + +export const inviteMember = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + + const result = await OrganizationService.inviteMember(user, req.body); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "User does not have an organization") + return next(AppError.notFound(error)); + return next(AppError.badRequest(error || "Failed to send invitation")); + } + + const output: InviteMemberOutput = ( + result as ISuccessPayload + ).data; + + return res.status(201).json({ + success: true, + message: "Invitation sent successfully", + data: output, + }); + }, +); + +export const acceptInvite = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const { token } = req.body; + const user = req.user as IUser; + + if (!user) { + await session.abortTransaction(); + session.endSession(); + return next(AppError.unauthorized("Authentication required")); + } + + const existingMemberships = await MembershipService.getMembershipsByUser( + user._id.toString(), + ); + const activeMembership = existingMemberships.find( + (m: IMembership) => m.status === "ACTIVE", + ); + + if (activeMembership) { + await session.abortTransaction(); + session.endSession(); + return next( + AppError.conflict("User already belongs to an organization"), + ); + } + + const inviteTokenHash = hashWithCrypto(token); + + const membership = await MembershipService.getPendingMembershipByHash( + inviteTokenHash, + session, + ); + + if (!membership) { + await session.abortTransaction(); + session.endSession(); + return next(AppError.notFound("Invite not found")); + } + + if ( + membership.inviteExpiresAt && + new Date() > membership.inviteExpiresAt + ) { + await session.abortTransaction(); + session.endSession(); + return next(new AppError("Invite expired", 410)); + } + + if ( + membership.email && + membership.email.toLowerCase() !== user.email.toLowerCase() + ) { + await session.abortTransaction(); + session.endSession(); + return next(AppError.forbidden("Invite email mismatch")); + } + + membership.status = "ACTIVE"; + membership.userId = user._id; + membership.acceptedAt = new Date(); + membership.joinedAt = new Date(); + membership.inviteTokenHash = null; + membership.inviteExpiresAt = null; + + await membership.save({ session }); + + await session.commitTransaction(); + + return res.status(200).json({ + success: true, + message: "Invitation accepted successfully", + data: { + membershipId: membership._id, + orgId: membership.orgId, + }, + }); + } catch (err) { + if (session.inTransaction()) { + await session.abortTransaction(); + } + return next(AppError.internal((err as Error).message)); + } finally { + session.endSession(); + } + }, +); diff --git a/src/modules/organization/organization.docs.ts b/src/modules/organization/organization.docs.ts new file mode 100644 index 0000000..c2a9b01 --- /dev/null +++ b/src/modules/organization/organization.docs.ts @@ -0,0 +1,78 @@ +import { Tspec } from "tspec"; +import { + CreateOrganizationInput, + CreateOrganizationOutput, + GetOrganizationOutput, + GetOrganizationMembersOutput, + InviteMemberInput, + InviteMemberOutput, + AcceptInviteInput, + AcceptInviteOutput, +} from "./organization.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type OrganizationApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/org"; + tags: ["Organization"]; + paths: { + "/": { + post: { + summary: "Create a new organization (one per user)"; + body: CreateOrganizationInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 409: IErrorPayload; + }; + }; + get: { + summary: "Get user's organization with caller's role"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + "/members": { + get: { + summary: "Get organization members (OWNER/MANAGER only)"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + "/invite": { + post: { + summary: "Invite a member to the organization (OWNER/MANAGER only)"; + body: InviteMemberInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + "/invite/accept": { + post: { + summary: "Accept an organization invitation"; + body: AcceptInviteInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + 409: IErrorPayload; + 410: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/organization/organization.model.ts b/src/modules/organization/organization.model.ts index 20e2607..6ecf154 100644 --- a/src/modules/organization/organization.model.ts +++ b/src/modules/organization/organization.model.ts @@ -1,6 +1,7 @@ import mongoose, { model } from "mongoose"; import { IOrganization } from "./organization.types"; import { slugify } from "@utils/index"; +import { ORG_STATUS } from "@constants"; const organizationSchema = new mongoose.Schema( { @@ -21,8 +22,8 @@ const organizationSchema = new mongoose.Schema( description: { type: String, trim: true }, status: { type: String, - enum: ["active", "inactive"], - default: "active", + enum: Object.values(ORG_STATUS), + default: ORG_STATUS.ACTIVE, }, size: { type: Number, diff --git a/src/modules/organization/organization.service.ts b/src/modules/organization/organization.service.ts new file mode 100644 index 0000000..c701d23 --- /dev/null +++ b/src/modules/organization/organization.service.ts @@ -0,0 +1,334 @@ +import mongoose from "mongoose"; +import OrganizationModel from "./organization.model"; +import MembershipService from "@modules/membership/membership.service"; +import UserService from "@modules/user/user.service"; +import { IUser } from "@modules/user/user.types"; +import { + CreateOrganizationInput, + CreateOrganizationOutput, + IOrganization, + InviteMemberInput, + InviteMemberOutput, + GetUserOrganizationOutput, + PendingMembershipData, +} from "./organization.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; +import { generateRandomTokenWithCrypto } from "@utils/generators"; +import { hashWithCrypto } from "@utils/encryptors"; +import { convertTimeToMilliseconds } from "@utils/index"; +import { sendInvitationEmail } from "./utils/invitationEmail"; +import { IMembership } from "@modules/membership/membership.types"; + +const OrganizationService = { + createOrganization: async ( + user: IUser, + input: CreateOrganizationInput, + ): Promise | IErrorPayload> => { + const existingMembership = await MembershipService.getMembershipsByUser( + user._id.toString(), + ); + + if (existingMembership.length > 0) { + return { + success: false, + error: "User already has an organization", + }; + } + + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const organization = new OrganizationModel({ + name: input.name, + owner: user._id, + size: input.size, + domain: input.domain, + description: input.description, + }); + + await organization.save({ session }); + + const membershipResult = await MembershipService.createMembership( + { + orgId: organization._id.toString(), + userId: user._id.toString(), + role: "OWNER", + status: "ACTIVE", + }, + session, + ); + + if (!membershipResult.success) { + throw new Error( + (membershipResult as IErrorPayload).error || + "Failed to create membership", + ); + } + + await session.commitTransaction(); + + return { + success: true, + data: { + organizationId: organization._id.toString(), + membershipId: ( + membershipResult as ISuccessPayload<{ + membershipId: string; + }> + ).data.membershipId, + }, + }; + } catch (err) { + if (session.inTransaction()) { + await session.abortTransaction(); + } + return { + success: false, + error: (err as Error).message, + }; + } finally { + session.endSession(); + } + }, + + getOrganizationById: async (id: string): Promise => { + return await OrganizationModel.findById(id); + }, + + getOrganizationsByOwner: async ( + ownerId: string, + ): Promise => { + return await OrganizationModel.find({ + owner: new mongoose.Types.ObjectId(ownerId), + }); + }, + + getUserOrganization: async ( + userId: string, + ): Promise | IErrorPayload> => { + try { + const memberships = await MembershipService.getMembershipsByUser(userId); + + if (memberships.length === 0) { + return { + success: false, + error: "User does not have an organization", + }; + } + + const membership = memberships[0]; + if (!membership) + return { + success: false, + error: "User does not have an organization", + }; + + const organization = await OrganizationModel.findById(membership.orgId); + + if (!organization) + return { + success: false, + error: "Organization not found", + }; + + return { + success: true, + data: { + organization, + role: membership.role, + }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getOrganizationMembers: async ( + userId: string, + ): Promise< + | ISuccessPayload<{ + members: Array<{ + membershipId: string; + userId: string; + firstName: string; + lastName: string; + email: string; + role: string; + status: string; + joinedAt: Date; + }>; + }> + | IErrorPayload + > => { + try { + const orgResult = await OrganizationService.getUserOrganization(userId); + + if (!orgResult.success) { + return { + success: false, + error: "User does not have an organization", + }; + } + + const organization = ( + orgResult as ISuccessPayload + ).data.organization; + + const memberships = await MembershipService.getMembershipsByOrg( + organization._id.toString(), + ); + + const members = memberships.map((membership) => { + const user = membership.userId as unknown as IUser; + return { + membershipId: membership._id.toString(), + userId: user._id.toString(), + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + role: membership.role, + status: membership.status, + joinedAt: membership.createdAt, + }; + }); + + return { + success: true, + data: { members }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + inviteMember: async ( + inviter: IUser, + input: InviteMemberInput, + ): Promise | IErrorPayload> => { + try { + const memberships = await MembershipService.getMembershipsByUser( + inviter._id.toString(), + ); + + if (memberships.length === 0) + return { + success: false, + error: "User does not have an organization", + }; + + const inviterMembership = memberships[0]; + const organization = await OrganizationService.getOrganizationById( + inviterMembership?.orgId?.toString() || "", + ); + + if (!organization) + return { + success: false, + error: "Organization not found", + }; + + const existingUser = await UserService.getUserByEmail(input.email); + + let existingMembership: IMembership | null = null; + if (existingUser) { + existingMembership = await MembershipService.getMembershipByUserAndOrg( + existingUser._id.toString(), + organization._id.toString(), + ); + } + + if (!existingMembership) { + existingMembership = await MembershipService.getMembershipByEmailAndOrg( + input.email, + organization._id.toString(), + ); + } + + if (existingMembership) { + if (existingMembership.status !== "PENDING") + return { + success: false, + error: "User is already a member of this organization", + }; + return { + success: false, + error: "An invitation has already been sent to this email", + }; + } + + const invitationToken = generateRandomTokenWithCrypto(32); + const inviteTokenHash = hashWithCrypto(invitationToken); + const inviteExpiresAt = new Date( + Date.now() + convertTimeToMilliseconds(168, "hours"), + ); + + const membershipData: PendingMembershipData = { + orgId: organization._id.toString(), + role: input.role, + status: "PENDING", + inviteTokenHash, // Store hash + inviteExpiresAt, // Store expiration + invitedBy: inviter._id.toString(), + }; + + if (existingUser) { + membershipData.userId = existingUser._id.toString(); + } else { + membershipData.email = input.email; + } + + const membershipResult = + await MembershipService.createMembership(membershipData); + + if (!membershipResult.success) { + return { + success: false, + error: + (membershipResult as IErrorPayload).error || + "Failed to create membership invitation", + }; + } + + const membershipId = ( + membershipResult as ISuccessPayload<{ membershipId: string }> + ).data.membershipId; + + const owner = await UserService.getUserById( + organization.owner.toString(), + ); + const ownersName = owner + ? `${owner.firstName} ${owner.lastName}` + : "Organization Owner"; + + const emailSentResponse = await sendInvitationEmail({ + email: input.email, + role: input.role, + organization, + invitationToken, + ownersName, + }); + + return { + success: true, + data: { + invitationId: membershipId, + emailSent: emailSentResponse.emailSent || false, + }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default OrganizationService; diff --git a/src/modules/organization/organization.types.ts b/src/modules/organization/organization.types.ts index 2e0eb88..a45c380 100644 --- a/src/modules/organization/organization.types.ts +++ b/src/modules/organization/organization.types.ts @@ -1,6 +1,10 @@ import mongoose from "mongoose"; - -type Status = "active" | "inactive"; +import { z } from "zod"; +import { + createOrganizationSchema, + acceptInviteSchema, +} from "./organization.validators"; +import { OrgStatus, UserRole } from "@constants"; export interface IOrganization extends mongoose.Document { _id: mongoose.Types.ObjectId; @@ -11,10 +15,84 @@ export interface IOrganization extends mongoose.Document { description?: string; createdAt: Date; updatedAt: Date; - status: Status; + status: OrgStatus; size: number; settings: { timezone: string; workHours: number; }; } + +export type CreateOrganizationInput = z.infer; + +export type CreateOrganizationOutput = { + organizationId: string; + membershipId: string; +}; + +export type GetOrganizationOutput = { + organization: { + id: string; + name: string; + slug: string; + domain?: string; + description?: string; + status: OrgStatus; + size: number; + settings: { + timezone: string; + workHours: number; + }; + createdAt: Date; + updatedAt: Date; + }; + role: string | UserRole; +}; + +export type OrganizationMember = { + membershipId: string; + userId: string; + firstName: string; + lastName: string; + email: string; + role: UserRole; + status: string; + joinedAt: Date; +}; + +export type GetOrganizationMembersOutput = { + members: OrganizationMember[]; +}; + +export type InviteMemberInput = { + email: string; + role: UserRole; +}; + +export type AcceptInviteInput = z.infer; + +export type AcceptInviteOutput = { + membershipId: string; + orgId: string; +}; + +export type InviteMemberOutput = { + invitationId: string; + emailSent: boolean; +}; + +export type GetUserOrganizationOutput = { + organization: IOrganization; + role: string | UserRole; +}; + +export type PendingMembershipData = { + orgId: string; + userId?: string; + email?: string; + role: UserRole; + status: "PENDING"; + inviteTokenHash: string; + inviteExpiresAt: Date; + invitedBy: string; +}; diff --git a/src/modules/organization/organization.validators.ts b/src/modules/organization/organization.validators.ts new file mode 100644 index 0000000..0e95d20 --- /dev/null +++ b/src/modules/organization/organization.validators.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { emailSchema } from "@modules/auth/auth.validators"; + +export const createOrganizationSchema = z.object({ + name: z + .string() + .min(2, "Organization name must be at least 2 characters long") + .max(100, "Organization name must not exceed 100 characters"), + size: z.number().int().min(1, "Organization size must be at least 1"), + domain: z.string().optional(), + description: z + .string() + .max(500, "Description must not exceed 500 characters") + .optional(), +}); + +export const inviteMemberSchema = z.object({ + email: emailSchema, + role: z.enum(["OWNER", "MANAGER", "MEMBER", "VIEWER"], { + errorMap: () => ({ message: "Invalid role" }), + }), +}); + +export const acceptInviteSchema = z.object({ + token: z.string({ required_error: "Token is required" }), +}); diff --git a/src/modules/organization/routes/organization.v1.routes.ts b/src/modules/organization/routes/organization.v1.routes.ts new file mode 100644 index 0000000..e86039b --- /dev/null +++ b/src/modules/organization/routes/organization.v1.routes.ts @@ -0,0 +1,53 @@ +import { Router } from "express"; +import validateResource from "@middlewares/validators"; +import authenticate from "@middlewares/authenticate"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +import { + createOrganizationSchema, + inviteMemberSchema, + acceptInviteSchema, +} from "../organization.validators"; +import { + createOrganization, + getOrganization, + getOrganizationMembers, + inviteMember, + acceptInvite, +} from "../organization.controller"; + +const organizationRouter = Router(); + +organizationRouter.post( + "/", + authenticate, + validateResource(createOrganizationSchema), + createOrganization, +); + +organizationRouter.get("/", authenticate, getOrganization); + +organizationRouter.get( + "/members", + authenticate, + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + getOrganizationMembers, +); + +organizationRouter.post( + "/invite", + authenticate, + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validateResource(inviteMemberSchema), + inviteMember, +); + +organizationRouter.post( + "/invite/accept", + authenticate, + validateResource(acceptInviteSchema), + acceptInvite, +); + +export default organizationRouter; diff --git a/src/modules/organization/utils/invitationEmail.ts b/src/modules/organization/utils/invitationEmail.ts new file mode 100644 index 0000000..41780ff --- /dev/null +++ b/src/modules/organization/utils/invitationEmail.ts @@ -0,0 +1,47 @@ +import { sendEmailWithTemplate } from "@services/email.service"; +import { FRONTEND_BASE_URL, INVITATION_TEMPLATE_KEY } from "@config/env"; +import { IOrganization } from "../organization.types"; +import { InviteMemberInput } from "../organization.types"; + +type SendInvitationEmailParams = { + email: string; + role: InviteMemberInput["role"]; + organization: IOrganization; + invitationToken: string; + ownersName: string; +}; + +export const sendInvitationEmail = async ({ + email, + role, + organization, + invitationToken, + ownersName, +}: SendInvitationEmailParams) => { + const inviteeName = email.split("@")[0] || "User"; + const acceptLink = `${FRONTEND_BASE_URL}/accept-invitation?token=${invitationToken}`; + const verifyAccountLink = `${FRONTEND_BASE_URL}/verify-account`; + + return await sendEmailWithTemplate({ + to: [ + { + email_address: { + address: email, + name: inviteeName, + }, + }, + ], + merge_info: { + role, + organizationName: organization.name, + name: inviteeName, + acceptLink, + verify_account_link: verifyAccountLink, + invitationLinkExpiry: "7 days", + ownersName, + }, + subject: `Invitation to join ${organization.name}`, + mail_template_key: INVITATION_TEMPLATE_KEY, + template_alias: "invitation", + }); +}; diff --git a/src/modules/project/__tests__/integration/project.v1.test.ts b/src/modules/project/__tests__/integration/project.v1.test.ts new file mode 100644 index 0000000..a1890aa --- /dev/null +++ b/src/modules/project/__tests__/integration/project.v1.test.ts @@ -0,0 +1,108 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; + +const { verifiedUserEmail } = TEST_CONSTANTS; + +describe("Project Module Integration Tests", () => { + let accessToken: string; + let orgId: string; + let clientId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + // Seed a client for project testing + const ClientModel = (await import("../../../client/client.model")).default; + const client = await new ClientModel({ name: "Test Client", orgId }).save(); + clientId = client.id.toString(); + }); + + describe("POST /api/v1/projects", () => { + it("should create a new project", async () => { + const res = await request(app) + .post("/api/v1/projects") + .set("Cookie", [accessToken]) + .send({ + name: "Website Redesign", + clientId: clientId, + isBillable: true, + orgId: orgId, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.name).toBe("Website Redesign"); + expect(res.body.data.clientId).toBe(clientId); + }); + }); + + describe("GET /api/v1/projects", () => { + it("should get all projects for organization", async () => { + const ProjectModel = (await import("@modules/project/project.model")) + .default; + await new ProjectModel({ name: "Project A", orgId, clientId }).save(); + + const res = await request(app) + .get("/api/v1/projects") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + }); + }); + + describe("PATCH /api/v1/projects/:id", () => { + it("should update project info", async () => { + const ProjectModel = (await import("@modules/project/project.model")) + .default; + const project = await new ProjectModel({ + name: "Old Project", + orgId, + clientId, + }).save(); + + const res = await request(app) + .patch(`/api/v1/projects/${project._id}`) + .set("Cookie", [accessToken]) + .send({ name: "New Project" }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe("New Project"); + }); + }); + + describe("DELETE /api/v1/projects/:id", () => { + it("should delete a project", async () => { + const ProjectModel = (await import("@modules/project/project.model")) + .default; + const project = await new ProjectModel({ + name: "To Delete", + orgId, + clientId, + }).save(); + + const res = await request(app) + .delete(`/api/v1/projects/${project._id}`) + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + const deletedProject = await ProjectModel.findById(project._id); + expect(deletedProject).toBeNull(); + }); + }); +}); diff --git a/src/modules/project/project.controller.ts b/src/modules/project/project.controller.ts new file mode 100644 index 0000000..690c4cc --- /dev/null +++ b/src/modules/project/project.controller.ts @@ -0,0 +1,105 @@ +import { Request, Response } from "express"; +import ProjectService from "./project.service"; +import AppError from "@utils/AppError"; +import { IErrorPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createProject = routeTryCatcher( + async (req: Request, res: Response) => { + const { name, clientId, color, isBillable, orgId } = req.body; + const userOrg = req.userOrg!; + + if (userOrg._id.toString() !== orgId) { + throw AppError.forbidden( + "You can only create projects for your own organization", + ); + } + + const result = await ProjectService.createProject({ + name, + clientId, + color, + isBillable, + orgId, + }); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(201).json(result); + }, +); + +export const getProjects = routeTryCatcher( + async (req: Request, res: Response) => { + const userOrg = req.userOrg!; + const projects = await ProjectService.getProjectsByOrgId( + userOrg._id.toString(), + ); + + res.status(200).json({ + success: true, + data: projects, + }); + }, +); + +export const updateProject = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Project ID is required"); + + const project = await ProjectService.getProjectById(id); + if (!project) throw AppError.notFound("Project not found"); + + if (userOrg._id.toString() !== project.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to update this project", + ); + } + + const result = await ProjectService.updateProject(id, req.body); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export const deleteProject = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Project ID is required"); + + const project = await ProjectService.getProjectById(id); + if (!project) throw AppError.notFound("Project not found"); + + if (userOrg._id.toString() !== project.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to delete this project", + ); + } + + const result = await ProjectService.deleteProject(id); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export default { + createProject, + getProjects, + updateProject, + deleteProject, +}; diff --git a/src/modules/project/project.docs.ts b/src/modules/project/project.docs.ts new file mode 100644 index 0000000..378a324 --- /dev/null +++ b/src/modules/project/project.docs.ts @@ -0,0 +1,59 @@ +import { Tspec } from "tspec"; +import { + CreateProjectInput, + UpdateProjectInput, + ProjectOutput, +} from "./project.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type ProjectApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/projects"; + tags: ["Projects"]; + paths: { + "/": { + post: { + summary: "Create a new project"; + body: CreateProjectInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + get: { + summary: "Get all projects for the organization"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a project"; + path: { id: string }; + body: UpdateProjectInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a project"; + path: { id: string }; + responses: { + 200: ISuccessPayload<{ message: string }>; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/project/project.model.ts b/src/modules/project/project.model.ts new file mode 100644 index 0000000..67a8d22 --- /dev/null +++ b/src/modules/project/project.model.ts @@ -0,0 +1,46 @@ +import mongoose, { Schema } from "mongoose"; +import { IProject } from "./project.types"; +import { GLOBAL_STATUS } from "@constants"; + +const projectSchema: Schema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + clientId: { + type: Schema.Types.ObjectId, + ref: "Client", + required: true, + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + color: { + type: String, + default: "#808080", + }, + isBillable: { + type: Boolean, + default: true, + }, + status: { + type: String, + enum: Object.values(GLOBAL_STATUS), + default: GLOBAL_STATUS.ACTIVE, + }, + }, + { + timestamps: true, + }, +); + +// Ensure name is unique within the same organization +projectSchema.index({ name: 1, orgId: 1 }, { unique: true }); + +const ProjectModel = mongoose.model("Project", projectSchema); + +export default ProjectModel; diff --git a/src/modules/project/project.service.ts b/src/modules/project/project.service.ts new file mode 100644 index 0000000..8d40f67 --- /dev/null +++ b/src/modules/project/project.service.ts @@ -0,0 +1,133 @@ +import { + CreateProjectInput, + UpdateProjectInput, + ProjectOutput, + ProjectBase, +} from "./project.types"; +import ProjectModel from "./project.model"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: ProjectBase & Mappable): ProjectOutput => { + const clientId = doc.clientId as unknown as + | { _id?: { toString(): string } } + | { toString(): string }; + return { + id: doc._id.toString(), + name: doc.name, + clientId: + ("_id" in clientId ? clientId._id?.toString() : clientId.toString()) || + "", + orgId: doc.orgId.toString(), + color: doc.color, + isBillable: doc.isBillable, + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const ProjectService = { + createProject: async ( + input: CreateProjectInput, + ): Promise | IErrorPayload> => { + try { + const existingProject = await ProjectModel.findOne({ + name: input.name, + orgId: input.orgId, + }); + + if (existingProject) { + return { + success: false, + error: "Project with this name already exists in the organization", + }; + } + + const project = new ProjectModel(input); + await project.save(); + + return { + success: true, + data: mapToOutput(project as unknown as ProjectBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getProjectsByOrgId: async (orgId: string): Promise => { + const projects = await ProjectModel.find({ + orgId, + status: "ACTIVE", + }).populate("clientId"); + return projects.map((doc) => + mapToOutput(doc as unknown as ProjectBase & Mappable), + ); + }, + + getProjectById: async (id: string): Promise => { + const project = await ProjectModel.findById(id).populate("clientId"); + return project + ? mapToOutput(project as unknown as ProjectBase & Mappable) + : null; + }, + + updateProject: async ( + id: string, + input: UpdateProjectInput, + ): Promise | IErrorPayload> => { + try { + const project = await ProjectModel.findById(id); + if (!project) { + return { + success: false, + error: "Project not found", + }; + } + + Object.assign(project, input); + await project.save(); + + return { + success: true, + data: mapToOutput(project as unknown as ProjectBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + deleteProject: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const project = await ProjectModel.findById(id); + if (!project) { + return { + success: false, + error: "Project not found", + }; + } + + await ProjectModel.findByIdAndDelete(id); + + return { + success: true, + data: { message: "Project deleted successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default ProjectService; diff --git a/src/modules/project/project.types.ts b/src/modules/project/project.types.ts new file mode 100644 index 0000000..7b8b905 --- /dev/null +++ b/src/modules/project/project.types.ts @@ -0,0 +1,44 @@ +import mongoose from "mongoose"; +import { GlobalStatus } from "@constants"; + +export interface ProjectBase { + name: string; + clientId: mongoose.Types.ObjectId; + orgId: mongoose.Types.ObjectId; + color: string; + isBillable: boolean; + status: GlobalStatus; +} + +export interface IProject extends ProjectBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface ProjectOutput { + id: string; + name: string; + clientId: string; + orgId: string; + color: string; + isBillable: boolean; + status: GlobalStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateProjectInput { + name: string; + clientId: string; + orgId: string; + color?: string; + isBillable?: boolean; +} + +export interface UpdateProjectInput { + name?: string; + clientId?: string; + color?: string; + isBillable?: boolean; + status?: GlobalStatus; +} diff --git a/src/modules/project/project.validators.ts b/src/modules/project/project.validators.ts new file mode 100644 index 0000000..054d4d3 --- /dev/null +++ b/src/modules/project/project.validators.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { GLOBAL_STATUS } from "@constants"; +import { zObjectId } from "@utils/validators"; + +export const createProjectSchema = z.object({ + name: z.string().min(1).max(100), + clientId: zObjectId, + orgId: zObjectId, + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), + isBillable: z.boolean().optional(), +}); + +export const updateProjectSchema = z.object({ + name: z.string().min(1).max(100).optional(), + clientId: zObjectId.optional(), + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), + isBillable: z.boolean().optional(), + status: z + .enum(Object.values(GLOBAL_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/project/routes/project.v1.routes.ts b/src/modules/project/routes/project.v1.routes.ts new file mode 100644 index 0000000..5f12532 --- /dev/null +++ b/src/modules/project/routes/project.v1.routes.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import ProjectController from "../project.controller"; +import authenticate from "@middlewares/authenticate"; +import validate from "@middlewares/validators"; +import { + createProjectSchema, + updateProjectSchema, +} from "../project.validators"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +const projectRouter = Router(); + +projectRouter.use(authenticate); + +projectRouter.post( + "/", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(createProjectSchema), + ProjectController.createProject, +); +projectRouter.get( + "/", + requireRole([ + USER_ROLES.OWNER, + USER_ROLES.MANAGER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, + ]), + ProjectController.getProjects, +); +projectRouter.patch( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(updateProjectSchema), + ProjectController.updateProject, +); +projectRouter.delete( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + ProjectController.deleteProject, +); + +export default projectRouter; diff --git a/src/modules/tag/__tests__/integration/tag.v1.test.ts b/src/modules/tag/__tests__/integration/tag.v1.test.ts new file mode 100644 index 0000000..f23ae01 --- /dev/null +++ b/src/modules/tag/__tests__/integration/tag.v1.test.ts @@ -0,0 +1,233 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg, seedUserInOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import OrganizationService from "@modules/organization/organization.service"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import { ISuccessPayload } from "src/types"; +import { GetOrganizationOutput } from "@modules/organization/organization.types"; + +const { verifiedUserEmail, testPassword } = TEST_CONSTANTS; + +describe("POST /api/v1/tags", () => { + let accessToken: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + const { user } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + password: testPassword, + isEmailVerified: true, + }); + + const orgResult = await OrganizationService.getUserOrganization( + user._id.toString(), + ); + if (!orgResult.success) throw new Error("Org not found"); + orgId = ( + orgResult as ISuccessPayload + ).data.organization.id.toString(); + + const token = generateAccessToken({ + id: user._id.toString(), + email: user.email, + }); + accessToken = createSignedAccessTokenCookie(token); + }); + + it("should create a new tag", async () => { + const res = await request(app) + .post("/api/v1/tags") + .set("Cookie", [accessToken]) + .send({ + name: "Billable", + color: "#FF0000", + orgId: orgId, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.name).toBe("Billable"); + expect(res.body.data.orgId).toBe(orgId); + }); + + it("should return 401 if not authenticated", async () => { + const res = await request(app).post("/api/v1/tags").send({ + name: "Billable", + orgId: orgId, + }); + + expect(res.status).toBe(401); + }); + + it("should return 403 if user is a MEMBER (only MANAGER/OWNER allowed)", async () => { + const { user } = await seedUserInOrg(orgId, {}, "MEMBER"); + const memberToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const res = await request(app) + .post("/api/v1/tags") + .set("Cookie", [memberToken]) + .send({ + name: "Member Tag", + color: "#0000FF", + orgId: orgId, + }); + + expect(res.status).toBe(403); + }); +}); + +describe("GET /api/v1/tags", () => { + let accessToken: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + // Create a tag directly via model for testing GET + const TagModel = (await import("../../tag.model")).default; + await new TagModel({ name: "Tag 1", orgId }).save(); + await new TagModel({ name: "Tag 2", orgId }).save(); + }); + + it("should get all tags for the organization", async () => { + const res = await request(app) + .get("/api/v1/tags") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBe(2); + }); + + it("should allow a VIEWER to get all tags", async () => { + const { user } = await seedUserInOrg(orgId, {}, "VIEWER"); + const viewerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const res = await request(app) + .get("/api/v1/tags") + .set("Cookie", [viewerToken]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); +}); + +describe("PATCH /api/v1/tags/:id", () => { + let accessToken: string; + let tagId: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const TagModel = (await import("../../tag.model")).default; + const tag = await new TagModel({ + name: "Old Name", + orgId: organization._id, + }).save(); + tagId = tag.id.toString(); + }); + + it("should update a tag name", async () => { + const res = await request(app) + .patch(`/api/v1/tags/${tagId}`) + .set("Cookie", [accessToken]) + .send({ name: "New Name" }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe("New Name"); + }); + + it("should return 403 if a VIEWER tries to update a tag", async () => { + // Seed a viewer in the SAME organization to specifically test role permissions (requireRole middleware) + const { user } = await seedUserInOrg(orgId, {}, "VIEWER"); + const viewerToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const res = await request(app) + .patch(`/api/v1/tags/${tagId}`) + .set("Cookie", [viewerToken]) + .send({ name: "Viewer Edit" }); + + expect(res.status).toBe(403); + }); +}); + +describe("DELETE /api/v1/tags/:id", () => { + let accessToken: string; + let tagId: string; + let orgId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const TagModel = (await import("../../tag.model")).default; + const tag = await new TagModel({ + name: "To Delete", + orgId: organization._id, + }).save(); + tagId = tag.id.toString(); + }); + + it("should delete a tag", async () => { + const res = await request(app) + .delete(`/api/v1/tags/${tagId}`) + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const TagModel = (await import("../../tag.model")).default; + const deletedTag = await TagModel.findById(tagId); + expect(deletedTag).toBeNull(); + }); + + it("should return 403 if a MEMBER tries to delete a tag", async () => { + // Seed a member in the SAME organization to specifically test role permissions (requireRole middleware) + const { user } = await seedUserInOrg(orgId, {}, "MEMBER"); + const memberToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + const res = await request(app) + .delete(`/api/v1/tags/${tagId}`) + .set("Cookie", [memberToken]); + + expect(res.status).toBe(403); + }); +}); diff --git a/src/modules/tag/routes/tag.v1.routes.ts b/src/modules/tag/routes/tag.v1.routes.ts new file mode 100644 index 0000000..261e04a --- /dev/null +++ b/src/modules/tag/routes/tag.v1.routes.ts @@ -0,0 +1,41 @@ +import { Router } from "express"; +import TagController from "../tag.controller"; +import authenticate from "@middlewares/authenticate"; +import validate from "@middlewares/validators"; +import { createTagSchema, updateTagSchema } from "../tag.validators"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +const tagRouter = Router(); + +tagRouter.use(authenticate); + +tagRouter.post( + "/", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(createTagSchema), + TagController.createTag, +); +tagRouter.get( + "/", + requireRole([ + USER_ROLES.OWNER, + USER_ROLES.MANAGER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, + ]), + TagController.getTags, +); +tagRouter.patch( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + validate(updateTagSchema), + TagController.updateTag, +); +tagRouter.delete( + "/:id", + requireRole([USER_ROLES.OWNER, USER_ROLES.MANAGER]), + TagController.deleteTag, +); + +export default tagRouter; diff --git a/src/modules/tag/tag.controller.ts b/src/modules/tag/tag.controller.ts new file mode 100644 index 0000000..2a1b987 --- /dev/null +++ b/src/modules/tag/tag.controller.ts @@ -0,0 +1,91 @@ +import { Request, Response } from "express"; +import TagService from "./tag.service"; +import AppError from "@utils/AppError"; +import { IErrorPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createTag = routeTryCatcher( + async (req: Request, res: Response) => { + const { name, color, orgId } = req.body; + const userOrg = req.userOrg!; + + if (userOrg._id.toString() !== orgId) { + throw AppError.forbidden( + "You can only create tags for your own organization", + ); + } + + const result = await TagService.createTag({ name, color, orgId }); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(201).json(result); + }, +); + +export const getTags = routeTryCatcher(async (req: Request, res: Response) => { + const userOrg = req.userOrg!; + const tags = await TagService.getTagsByOrgId(userOrg._id.toString()); + + res.status(200).json({ + success: true, + data: tags, + }); +}); + +export const updateTag = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Tag ID is required"); + + const tag = await TagService.getTagById(id); + if (!tag) throw AppError.notFound("Tag not found"); + + if (userOrg._id.toString() !== tag.orgId.toString()) { + throw AppError.forbidden("You do not have permission to update this tag"); + } + + const result = await TagService.updateTag(id, req.body); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export const deleteTag = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Tag ID is required"); + + const tag = await TagService.getTagById(id); + if (!tag) throw AppError.notFound("Tag not found"); + + if (userOrg._id.toString() !== tag.orgId.toString()) { + throw AppError.forbidden("You do not have permission to delete this tag"); + } + + const result = await TagService.deleteTag(id); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export default { + createTag, + getTags, + updateTag, + deleteTag, +}; diff --git a/src/modules/tag/tag.docs.ts b/src/modules/tag/tag.docs.ts new file mode 100644 index 0000000..287ab2a --- /dev/null +++ b/src/modules/tag/tag.docs.ts @@ -0,0 +1,55 @@ +import { Tspec } from "tspec"; +import { CreateTagInput, UpdateTagInput, TagOutput } from "./tag.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type TagApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/tags"; + tags: ["Tags"]; + paths: { + "/": { + post: { + summary: "Create a new tag"; + body: CreateTagInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + get: { + summary: "Get all tags for the organization"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a tag"; + path: { id: string }; + body: UpdateTagInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a tag"; + path: { id: string }; + responses: { + 200: ISuccessPayload<{ message: string }>; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/tag/tag.model.ts b/src/modules/tag/tag.model.ts new file mode 100644 index 0000000..f0be5c6 --- /dev/null +++ b/src/modules/tag/tag.model.ts @@ -0,0 +1,37 @@ +import mongoose, { Schema } from "mongoose"; +import { ITag } from "./tag.types"; +import { GLOBAL_STATUS } from "@constants"; + +const tagSchema: Schema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + color: { + type: String, + default: "#808080", // Default gray + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + status: { + type: String, + enum: Object.values(GLOBAL_STATUS), + default: GLOBAL_STATUS.ACTIVE, + }, + }, + { + timestamps: true, + }, +); + +// Ensure name is unique within the same organization +tagSchema.index({ name: 1, orgId: 1 }, { unique: true }); + +const TagModel = mongoose.model("Tag", tagSchema); + +export default TagModel; diff --git a/src/modules/tag/tag.service.ts b/src/modules/tag/tag.service.ts new file mode 100644 index 0000000..e4fe825 --- /dev/null +++ b/src/modules/tag/tag.service.ts @@ -0,0 +1,127 @@ +import { + CreateTagInput, + UpdateTagInput, + TagOutput, + TagBase, +} from "./tag.types"; +import TagModel from "./tag.model"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: TagBase & Mappable): TagOutput => { + return { + id: doc._id.toString(), + name: doc.name, + color: doc.color ?? undefined, + orgId: doc.orgId.toString(), + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const TagService = { + createTag: async ( + input: CreateTagInput, + ): Promise | IErrorPayload> => { + try { + const existingTag = await TagModel.findOne({ + name: input.name, + orgId: input.orgId, + }); + + if (existingTag) { + return { + success: false, + error: "Tag with this name already exists in the organization", + }; + } + + const tag = new TagModel({ + name: input.name, + color: input.color, + orgId: input.orgId, + }); + + await tag.save(); + + return { + success: true, + data: mapToOutput(tag as unknown as TagBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getTagsByOrgId: async (orgId: string): Promise => { + const tags = await TagModel.find({ orgId, status: "ACTIVE" }); + return tags.map((doc) => mapToOutput(doc as unknown as TagBase & Mappable)); + }, + + getTagById: async (id: string): Promise => { + const tag = await TagModel.findById(id); + return tag ? mapToOutput(tag as unknown as TagBase & Mappable) : null; + }, + + updateTag: async ( + id: string, + input: UpdateTagInput, + ): Promise | IErrorPayload> => { + try { + const tag = await TagModel.findById(id); + if (!tag) { + return { + success: false, + error: "Tag not found", + }; + } + + if (input.name) tag.name = input.name; + if (input.color) tag.color = input.color; + if (input.status) tag.status = input.status; + + await tag.save(); + + return { + success: true, + data: mapToOutput(tag as unknown as TagBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + deleteTag: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const tag = await TagModel.findById(id); + if (!tag) { + return { + success: false, + error: "Tag not found", + }; + } + + await TagModel.findByIdAndDelete(id); + + return { + success: true, + data: { message: "Tag deleted successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default TagService; diff --git a/src/modules/tag/tag.types.ts b/src/modules/tag/tag.types.ts new file mode 100644 index 0000000..e7450e5 --- /dev/null +++ b/src/modules/tag/tag.types.ts @@ -0,0 +1,36 @@ +import mongoose from "mongoose"; +import { GlobalStatus } from "@constants"; + +export interface TagBase { + name: string; + color?: string | undefined; + orgId: mongoose.Types.ObjectId; + status: GlobalStatus; +} + +export interface ITag extends TagBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface TagOutput { + id: string; + name: string; + color?: string | undefined; + orgId: string; + status: GlobalStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTagInput { + name: string; + color?: string; + orgId: string; +} + +export interface UpdateTagInput { + name?: string; + color?: string; + status?: GlobalStatus; +} diff --git a/src/modules/tag/tag.validators.ts b/src/modules/tag/tag.validators.ts new file mode 100644 index 0000000..8598285 --- /dev/null +++ b/src/modules/tag/tag.validators.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { GLOBAL_STATUS } from "@constants"; +import { zObjectId } from "@utils/validators"; + +export const createTagSchema = z.object({ + name: z.string().min(1).max(50), + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), + orgId: zObjectId, +}); + +export const updateTagSchema = z.object({ + name: z.string().min(1).max(50).optional(), + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), + status: z + .enum(Object.values(GLOBAL_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/task/__tests__/integration/task.v1.test.ts b/src/modules/task/__tests__/integration/task.v1.test.ts new file mode 100644 index 0000000..1d303c9 --- /dev/null +++ b/src/modules/task/__tests__/integration/task.v1.test.ts @@ -0,0 +1,130 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; + +const { verifiedUserEmail } = TEST_CONSTANTS; + +describe("Task Module Integration Tests", () => { + let accessToken: string; + let orgId: string; + let projectId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: user._id.toString(), email: user.email }), + ); + + // Seed Client and Project for task testing + const ClientModel = (await import("../../../client/client.model")).default; + const ProjectModel = (await import("../../../project/project.model")) + .default; + + const client = await new ClientModel({ name: "Test Client", orgId }).save(); + const project = await new ProjectModel({ + name: "Web App", + orgId, + clientId: client._id, + }).save(); + projectId = project.id.toString(); + }); + + describe("POST /api/v1/tasks", () => { + it("should create a new task", async () => { + const res = await request(app) + .post("/api/v1/tasks") + .set("Cookie", [accessToken]) + .send({ + name: "Design Layout", + projectId: projectId, + orgId: orgId, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.name).toBe("Design Layout"); + expect(res.body.data.projectId).toBe(projectId); + }); + }); + + describe("GET /api/v1/tasks", () => { + it("should get all tasks for organization", async () => { + const TaskModel = (await import("@modules/task/task.model")).default; + await new TaskModel({ name: "Task 1", projectId, orgId }).save(); + + const res = await request(app) + .get("/api/v1/tasks") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + }); + }); + + describe("PATCH /api/v1/tasks/:id", () => { + it("should update task info", async () => { + const TaskModel = (await import("@modules/task/task.model")).default; + const task = await new TaskModel({ + name: "Old Task", + projectId, + orgId, + }).save(); + + const res = await request(app) + .patch(`/api/v1/tasks/${task._id}`) + .set("Cookie", [accessToken]) + .send({ name: "New Task", status: "DONE" }); + + expect(res.status).toBe(200); + expect(res.body.data.name).toBe("New Task"); + expect(res.body.data.status).toBe("DONE"); + }); + + it("should update task status to IN_PROGRESS", async () => { + const TaskModel = (await import("@modules/task/task.model")).default; + const task = await new TaskModel({ + name: "Work Task", + projectId, + orgId, + }).save(); + + const res = await request(app) + .patch(`/api/v1/tasks/${task._id}`) + .set("Cookie", [accessToken]) + .send({ status: "IN_PROGRESS" }); + + expect(res.status).toBe(200); + expect(res.body.data.status).toBe("IN_PROGRESS"); + }); + }); + + describe("DELETE /api/v1/tasks/:id", () => { + it("should delete a task", async () => { + const TaskModel = (await import("@modules/task/task.model")).default; + const task = await new TaskModel({ + name: "To Delete", + projectId, + orgId, + }).save(); + + const res = await request(app) + .delete(`/api/v1/tasks/${task._id}`) + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + const deletedTask = await TaskModel.findById(task._id); + expect(deletedTask).toBeNull(); + }); + }); +}); diff --git a/src/modules/task/routes/task.v1.routes.ts b/src/modules/task/routes/task.v1.routes.ts new file mode 100644 index 0000000..7bec3a5 --- /dev/null +++ b/src/modules/task/routes/task.v1.routes.ts @@ -0,0 +1,41 @@ +import { Router } from "express"; +import TaskController from "../task.controller"; +import authenticate from "@middlewares/authenticate"; +import validate from "@middlewares/validators"; +import { createTaskSchema, updateTaskSchema } from "../task.validators"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; + +const taskRouter = Router(); + +taskRouter.use(authenticate); + +taskRouter.post( + "/", + requireRole([USER_ROLES.MANAGER, USER_ROLES.OWNER]), + validate(createTaskSchema), + TaskController.createTask, +); +taskRouter.get( + "/", + requireRole([ + USER_ROLES.MANAGER, + USER_ROLES.OWNER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, + ]), + TaskController.getTasks, +); +taskRouter.patch( + "/:id", + requireRole([USER_ROLES.MANAGER, USER_ROLES.OWNER]), + validate(updateTaskSchema), + TaskController.updateTask, +); +taskRouter.delete( + "/:id", + requireRole([USER_ROLES.MANAGER, USER_ROLES.OWNER]), + TaskController.deleteTask, +); + +export default taskRouter; diff --git a/src/modules/task/task.controller.ts b/src/modules/task/task.controller.ts new file mode 100644 index 0000000..e8cc023 --- /dev/null +++ b/src/modules/task/task.controller.ts @@ -0,0 +1,109 @@ +import { Request, Response } from "express"; +import TaskService from "./task.service"; +import ProjectService from "@modules/project/project.service"; +import AppError from "@utils/AppError"; +import { IErrorPayload } from "src/types"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; + +export const createTask = routeTryCatcher( + async (req: Request, res: Response) => { + const { name, projectId, isBillable, orgId } = req.body; + const userOrg = req.userOrg!; + + if (userOrg._id.toString() !== orgId) { + throw AppError.forbidden( + "You can only create tasks for your own organization", + ); + } + + // Verify project belongs to organization + const project = await ProjectService.getProjectById(projectId); + if (!project || project.orgId.toString() !== userOrg._id.toString()) { + throw AppError.badRequest( + "Invalid project or project does not belong to your organization", + ); + } + + const result = await TaskService.createTask({ + name, + projectId, + isBillable, + orgId, + }); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(201).json(result); + }, +); + +export const getTasks = routeTryCatcher(async (req: Request, res: Response) => { + const userOrg = req.userOrg!; + const tasks = await TaskService.getTasksByOrgId(userOrg._id.toString()); + + res.status(200).json({ + success: true, + data: tasks, + }); +}); + +export const updateTask = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Task ID is required"); + + const task = await TaskService.getTaskById(id); + if (!task) throw AppError.notFound("Task not found"); + + if (userOrg._id.toString() !== task.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to update this task", + ); + } + + const result = await TaskService.updateTask(id, req.body); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export const deleteTask = routeTryCatcher( + async (req: Request, res: Response) => { + const { id } = req.params; + const userOrg = req.userOrg!; + + if (!id) throw AppError.badRequest("Task ID is required"); + + const task = await TaskService.getTaskById(id); + if (!task) throw AppError.notFound("Task not found"); + + if (userOrg._id.toString() !== task.orgId.toString()) { + throw AppError.forbidden( + "You do not have permission to delete this task", + ); + } + + const result = await TaskService.deleteTask(id); + + if (!result.success) { + throw AppError.badRequest((result as IErrorPayload).error); + } + + res.status(200).json(result); + }, +); + +export default { + createTask, + getTasks, + updateTask, + deleteTask, +}; diff --git a/src/modules/task/task.docs.ts b/src/modules/task/task.docs.ts new file mode 100644 index 0000000..31faf46 --- /dev/null +++ b/src/modules/task/task.docs.ts @@ -0,0 +1,55 @@ +import { Tspec } from "tspec"; +import { CreateTaskInput, UpdateTaskInput, TaskOutput } from "./task.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type TaskApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/tasks"; + tags: ["Tasks"]; + paths: { + "/": { + post: { + summary: "Create a new task"; + body: CreateTaskInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + get: { + summary: "Get all tasks for the organization"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + 403: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a task"; + path: { id: string }; + body: UpdateTaskInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a task"; + path: { id: string }; + responses: { + 200: ISuccessPayload<{ message: string }>; + 400: IErrorPayload; + 401: IErrorPayload; + 403: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/task/task.model.ts b/src/modules/task/task.model.ts new file mode 100644 index 0000000..7e32607 --- /dev/null +++ b/src/modules/task/task.model.ts @@ -0,0 +1,42 @@ +import mongoose, { Schema } from "mongoose"; +import { ITask } from "./task.types"; +import { TASK_STATUS } from "@constants"; + +const taskSchema: Schema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + projectId: { + type: Schema.Types.ObjectId, + ref: "Project", + required: true, + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + isBillable: { + type: Boolean, + default: true, + }, + status: { + type: String, + enum: Object.values(TASK_STATUS), + default: TASK_STATUS.TODO, + }, + }, + { + timestamps: true, + }, +); + +// Ensure task name is unique within the same project +taskSchema.index({ name: 1, projectId: 1 }, { unique: true }); + +const TaskModel = mongoose.model("Task", taskSchema); + +export default TaskModel; diff --git a/src/modules/task/task.service.ts b/src/modules/task/task.service.ts new file mode 100644 index 0000000..4f4cf92 --- /dev/null +++ b/src/modules/task/task.service.ts @@ -0,0 +1,130 @@ +import { + CreateTaskInput, + UpdateTaskInput, + TaskOutput, + TaskBase, +} from "./task.types"; +import TaskModel from "./task.model"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: TaskBase & Mappable): TaskOutput => { + const projectId = doc.projectId as unknown as + | { _id?: { toString(): string } } + | { toString(): string }; + return { + id: doc._id.toString(), + name: doc.name, + projectId: + ("_id" in projectId ? projectId._id?.toString() : projectId.toString()) || + "", + orgId: doc.orgId.toString(), + isBillable: doc.isBillable, + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const TaskService = { + createTask: async ( + input: CreateTaskInput, + ): Promise | IErrorPayload> => { + try { + const existingTask = await TaskModel.findOne({ + name: input.name, + projectId: input.projectId, + }); + + if (existingTask) { + return { + success: false, + error: "Task with this name already exists in the project", + }; + } + + const task = new TaskModel(input); + await task.save(); + + return { + success: true, + data: mapToOutput(task as unknown as TaskBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + getTasksByOrgId: async (orgId: string): Promise => { + const tasks = await TaskModel.find({ + orgId, + status: { $ne: "ARCHIVED" }, + }).populate("projectId"); + return tasks.map((doc) => + mapToOutput(doc as unknown as TaskBase & Mappable), + ); + }, + + getTaskById: async (id: string): Promise => { + const task = await TaskModel.findById(id).populate("projectId"); + return task ? mapToOutput(task as unknown as TaskBase & Mappable) : null; + }, + + updateTask: async ( + id: string, + input: UpdateTaskInput, + ): Promise | IErrorPayload> => { + try { + const task = await TaskModel.findById(id); + if (!task) { + return { + success: false, + error: "Task not found", + }; + } + + Object.assign(task, input); + await task.save(); + + return { + success: true, + data: mapToOutput(task as unknown as TaskBase & Mappable), + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, + + deleteTask: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const task = await TaskModel.findById(id); + if (!task) { + return { + success: false, + error: "Task not found", + }; + } + + await TaskModel.findByIdAndDelete(id); + + return { + success: true, + data: { message: "Task deleted successfully" }, + }; + } catch (err) { + return { + success: false, + error: (err as Error).message, + }; + } + }, +}; + +export default TaskService; diff --git a/src/modules/task/task.types.ts b/src/modules/task/task.types.ts new file mode 100644 index 0000000..b822be2 --- /dev/null +++ b/src/modules/task/task.types.ts @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; +import { TaskStatus } from "@constants"; + +export interface TaskBase { + name: string; + projectId: mongoose.Types.ObjectId; + orgId: mongoose.Types.ObjectId; + isBillable: boolean; + status: TaskStatus; +} + +export interface ITask extends TaskBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface TaskOutput { + id: string; + name: string; + projectId: string; + orgId: string; + isBillable: boolean; + status: TaskStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTaskInput { + name: string; + projectId: string; + orgId: string; + isBillable?: boolean; +} + +export interface UpdateTaskInput { + name?: string; + projectId?: string; + isBillable?: boolean; + status?: TaskStatus; +} diff --git a/src/modules/task/task.validators.ts b/src/modules/task/task.validators.ts new file mode 100644 index 0000000..7bfe42b --- /dev/null +++ b/src/modules/task/task.validators.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { TASK_STATUS } from "@constants"; +import { zObjectId } from "@utils/validators"; + +export const createTaskSchema = z.object({ + name: z.string().min(1).max(100), + projectId: zObjectId, + orgId: zObjectId, + isBillable: z.boolean().optional(), +}); + +export const updateTaskSchema = z.object({ + name: z.string().min(1).max(100).optional(), + projectId: zObjectId.optional(), + isBillable: z.boolean().optional(), + status: z + .enum(Object.values(TASK_STATUS) as [string, ...string[]]) + .optional(), +}); diff --git a/src/modules/time-entry/__tests__/integration/time-entry.v1.test.ts b/src/modules/time-entry/__tests__/integration/time-entry.v1.test.ts new file mode 100644 index 0000000..6bf9592 --- /dev/null +++ b/src/modules/time-entry/__tests__/integration/time-entry.v1.test.ts @@ -0,0 +1,198 @@ +import request from "supertest"; +import app from "@app"; +import { clearDB } from "@tests/utils"; +import { seedOneUserWithOrg } from "@tests/helpers/seed"; +import { + TEST_CONSTANTS, + createSignedAccessTokenCookie, +} from "../../../auth/__tests__/helpers/testHelpers"; +import { generateAccessToken } from "@modules/auth/utils/auth.tokens"; +import TimeEntryModel from "../../time-entry.model"; +import UserModel from "@modules/user/user.model"; + +const { verifiedUserEmail } = TEST_CONSTANTS; + +jest.setTimeout(30000); + +describe("Time Entry Module Integration Tests", () => { + let accessToken: string; + let userId: string; + let orgId: string; + let projectId: string; + let taskId: string; + + beforeEach(async () => { + await clearDB(); + const { user, organization } = await seedOneUserWithOrg({ + email: verifiedUserEmail, + isEmailVerified: true, + }); + userId = user._id.toString(); + orgId = organization._id.toString(); + accessToken = createSignedAccessTokenCookie( + generateAccessToken({ id: userId, email: user.email }), + ); + + // Seed Client, Project and Task + const ClientModel = (await import("../../../client/client.model")).default; + const ProjectModel = (await import("../../../project/project.model")) + .default; + const TaskModel = (await import("../../../task/task.model")).default; + + const client = await new ClientModel({ name: "Client X", orgId }).save(); + const clientId = client.id.toString(); + + const project = await new ProjectModel({ + name: "Project A", + orgId, + clientId, + }).save(); + projectId = project.id.toString(); + + const task = await new TaskModel({ + name: "Task 1", + projectId, + orgId, + }).save(); + taskId = task.id.toString(); + }); + + describe("POST /api/v1/time-entries/start", () => { + it("should start a new timer", async () => { + const res = await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ + description: "Coding Phase 3", + projectId, + taskId, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.description).toBe("Coding Phase 3"); + expect(res.body.data.endTime).toBeNull(); + expect(res.body.data.isManual).toBe(false); + expect(res.body.data.projectId).toBe(projectId); + expect(res.body.data.taskId).toBe(taskId); + + const user = await UserModel.findById(userId); + expect(user?.activeTimerId?.toString()).toBe(res.body.data.id); + }); + + it("should auto-stop the previous timer when starting a new one", async () => { + // Start first timer + const res1 = await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ description: "First Task", projectId, taskId }); + + const firstId = res1.body.data.id; + + // Start second timer + const res2 = await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ description: "Second Task", projectId, taskId }); + + expect(res2.status).toBe(201); + + const stoppedFirstEntry = await TimeEntryModel.findById(firstId); + expect(stoppedFirstEntry?.endTime).not.toBeNull(); + + const user = await UserModel.findById(userId); + expect(user?.activeTimerId?.toString()).toBe(res2.body.data.id as string); + }); + }); + + describe("POST /api/v1/time-entries/stop", () => { + it("should stop the active timer", async () => { + await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ description: "To be stopped", projectId, taskId }); + + const res = await request(app) + .post("/api/v1/time-entries/stop") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data.endTime).not.toBeNull(); + expect(res.body.data.duration).toBeGreaterThanOrEqual(0); + + const user = await UserModel.findById(userId); + expect(user?.activeTimerId).toBeNull(); + }); + + it("should return 404 if no active timer exists", async () => { + const res = await request(app) + .post("/api/v1/time-entries/stop") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); + }); + + describe("POST /api/v1/time-entries/manual", () => { + it("should create a manual entry", async () => { + const startTime = new Date(Date.now() - 3600000).toISOString(); + const endTime = new Date().toISOString(); + + const res = await request(app) + .post("/api/v1/time-entries/manual") + .set("Cookie", [accessToken]) + .send({ + description: "Manual Entry", + startTime, + endTime, + projectId, + taskId, + }); + + expect(res.status).toBe(201); + expect(res.body.data.duration).toBeGreaterThan(0); + expect(res.body.data.description).toBe("Manual Entry"); + expect(res.body.data.isManual).toBe(true); + expect(res.body.data.projectId).toBe(projectId); + }); + }); + + describe("GET /api/v1/time-entries/active", () => { + it("should get the active timer", async () => { + await request(app) + .post("/api/v1/time-entries/start") + .set("Cookie", [accessToken]) + .send({ description: "Active", projectId, taskId }); + + const res = await request(app) + .get("/api/v1/time-entries/active") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data).not.toBeNull(); + expect(res.body.data.description).toBe("Active"); + }); + }); + + describe("GET /api/v1/time-entries", () => { + it("should list time entries", async () => { + await new TimeEntryModel({ + userId, + orgId, + projectId, + taskId, + startTime: new Date(), + description: "Entry 1", + }).save(); + + const res = await request(app) + .get("/api/v1/time-entries") + .set("Cookie", [accessToken]); + + expect(res.status).toBe(200); + expect(res.body.data.length).toBe(1); + expect(res.body.data[0].description).toBe("Entry 1"); + }); + }); +}); diff --git a/src/modules/time-entry/routes/time-entry.v1.routes.ts b/src/modules/time-entry/routes/time-entry.v1.routes.ts new file mode 100644 index 0000000..ff5e3b1 --- /dev/null +++ b/src/modules/time-entry/routes/time-entry.v1.routes.ts @@ -0,0 +1,53 @@ +import { Router } from "express"; +import validateResource from "@middlewares/validators"; +import authenticate from "@middlewares/authenticate"; +import requireRole from "@middlewares/requireRole"; +import { USER_ROLES } from "@constants"; +import { + createTimeEntrySchema, + startTimeEntrySchema, + updateTimeEntrySchema, +} from "../time-entry.validators"; +import { + startTimeEntry, + stopTimeEntry, + createManualEntry, + getActiveEntry, + listEntries, + updateEntry, + deleteEntry, +} from "../time-entry.controller"; + +const timeEntryRouter = Router(); + +const allApprovedRoles = [ + USER_ROLES.OWNER, + USER_ROLES.MANAGER, + USER_ROLES.MEMBER, + USER_ROLES.VIEWER, +]; + +timeEntryRouter.use(authenticate); +timeEntryRouter.use(requireRole(allApprovedRoles)); + +timeEntryRouter.post( + "/start", + validateResource(startTimeEntrySchema), + startTimeEntry, +); +timeEntryRouter.post("/stop", stopTimeEntry); +timeEntryRouter.post( + "/manual", + validateResource(createTimeEntrySchema), + createManualEntry, +); +timeEntryRouter.get("/active", getActiveEntry); +timeEntryRouter.get("/", listEntries); +timeEntryRouter.patch( + "/:id", + validateResource(updateTimeEntrySchema), + updateEntry, +); +timeEntryRouter.delete("/:id", deleteEntry); + +export default timeEntryRouter; diff --git a/src/modules/time-entry/time-entry.controller.ts b/src/modules/time-entry/time-entry.controller.ts new file mode 100644 index 0000000..a9551f6 --- /dev/null +++ b/src/modules/time-entry/time-entry.controller.ts @@ -0,0 +1,169 @@ +import { Request, Response, NextFunction } from "express"; +import TimeEntryService from "./time-entry.service"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; +import { IUser } from "@modules/user/user.types"; +import { IOrganization } from "@modules/organization/organization.types"; +import AppError from "@utils/AppError"; +import { IErrorPayload, ISuccessPayload } from "src/types"; +import { TimeEntryOutput } from "./time-entry.types"; + +export const startTimeEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + const org = req.userOrg as IOrganization; + + const result = await TimeEntryService.startTimeEntry( + user._id.toString(), + org._id.toString(), + req.body, + ); + + if ((result as IErrorPayload).error) { + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Failed to start timer", + ), + ); + } + + return res.status(201).json({ + success: true, + message: "Timer started successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const stopTimeEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + + const result = await TimeEntryService.stopTimeEntry(user._id.toString()); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "No active timer found") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error || "Failed to stop timer")); + } + + return res.status(200).json({ + success: true, + message: "Timer stopped successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const createManualEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const user = req.user as IUser; + const org = req.userOrg as IOrganization; + + const result = await TimeEntryService.createManualEntry( + user._id.toString(), + org._id.toString(), + req.body, + ); + + if ((result as IErrorPayload).error) { + return next( + AppError.badRequest( + (result as IErrorPayload).error || "Failed to create manual entry", + ), + ); + } + + return res.status(201).json({ + success: true, + message: "Manual time entry created successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const getActiveEntry = routeTryCatcher( + async (req: Request, res: Response) => { + const user = req.user as IUser; + const activeEntry = await TimeEntryService.getActiveEntry( + user._id.toString(), + ); + + return res.status(200).json({ + success: true, + message: "Active entry retrieved successfully", + data: activeEntry, + }); + }, +); + +export const listEntries = routeTryCatcher( + async (req: Request, res: Response) => { + const user = req.user as IUser; + const org = req.userOrg as IOrganization; + const { projectId, startDate, endDate } = req.query; + + const entries = await TimeEntryService.listEntries( + user._id.toString(), + org._id.toString(), + { + projectId: projectId as string | undefined, + startDate: startDate ? new Date(startDate as string) : undefined, + endDate: endDate ? new Date(endDate as string) : undefined, + }, + ); + + return res.status(200).json({ + success: true, + message: "Time entries retrieved successfully", + data: entries, + }); + }, +); + +export const updateEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const { id } = req.params; + if (!id) return next(AppError.badRequest("Entry ID is required")); + + const result = await TimeEntryService.updateEntry(id, req.body); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "Time entry not found") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error || "Failed to update entry")); + } + + return res.status(200).json({ + success: true, + message: "Time entry updated successfully", + data: (result as ISuccessPayload).data, + }); + }, +); + +export const deleteEntry = routeTryCatcher( + async (req: Request, res: Response, next: NextFunction) => { + const { id } = req.params; + if (!id) return next(AppError.badRequest("Entry ID is required")); + + const result = await TimeEntryService.deleteEntry(id); + + if ((result as IErrorPayload).error) { + const error = (result as IErrorPayload).error; + if (error === "Time entry not found") { + return next(AppError.notFound(error)); + } + return next(AppError.badRequest(error || "Failed to delete entry")); + } + + return res.status(200).json({ + success: true, + message: "Time entry deleted successfully", + data: null, + }); + }, +); diff --git a/src/modules/time-entry/time-entry.docs.ts b/src/modules/time-entry/time-entry.docs.ts new file mode 100644 index 0000000..b01aeb6 --- /dev/null +++ b/src/modules/time-entry/time-entry.docs.ts @@ -0,0 +1,94 @@ +import { Tspec } from "tspec"; +import { + CreateTimeEntryInput, + StartTimeEntryInput, + UpdateTimeEntryInput, + TimeEntryOutput, +} from "./time-entry.types"; +import { ISuccessPayload, IErrorPayload } from "src/types"; + +export type TimeEntryApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/time-entries"; + tags: ["Time Entries"]; + paths: { + "/start": { + post: { + summary: "Start a new timer"; + body: StartTimeEntryInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; + "/stop": { + post: { + summary: "Stop the currently active timer"; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + "/manual": { + post: { + summary: "Create a manual (completed) time entry"; + body: CreateTimeEntryInput; + responses: { + 201: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; + "/active": { + get: { + summary: "Get the currently active timer for the user"; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + }; + }; + }; + "/": { + get: { + summary: "List time entries with filters"; + query: { + projectId?: string; + startDate?: string; + endDate?: string; + }; + responses: { + 200: ISuccessPayload; + 401: IErrorPayload; + }; + }; + }; + "/{id}": { + patch: { + summary: "Update a time entry"; + path: { id: string }; + body: UpdateTimeEntryInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 404: IErrorPayload; + }; + }; + delete: { + summary: "Delete a time entry"; + path: { id: string }; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + 404: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/time-entry/time-entry.model.ts b/src/modules/time-entry/time-entry.model.ts new file mode 100644 index 0000000..56b0f5e --- /dev/null +++ b/src/modules/time-entry/time-entry.model.ts @@ -0,0 +1,80 @@ +import mongoose, { Schema } from "mongoose"; +import { ITimeEntry } from "./time-entry.types"; + +const timeEntrySchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + orgId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + projectId: { + type: Schema.Types.ObjectId, + ref: "Project", + required: true, + }, + taskId: { + type: Schema.Types.ObjectId, + ref: "Task", + required: true, + }, + description: { + type: String, + trim: true, + default: "", + }, + startTime: { + type: Date, + required: true, + }, + endTime: { + type: Date, + default: null, + }, + duration: { + type: Number, + default: 0, + }, + isBillable: { + type: Boolean, + default: true, + }, + isManual: { + type: Boolean, + default: false, + }, + tags: [ + { + type: Schema.Types.ObjectId, + ref: "Tag", + }, + ], + }, + { + timestamps: true, + }, +); + +// Compound index for fast retrieval of recent entries for a specific user +timeEntrySchema.index({ userId: 1, startTime: -1 }); + +// Pre-save hook to calculate duration in milliseconds +timeEntrySchema.pre("save", function (next) { + const self = this as unknown as ITimeEntry; + if (self.endTime && self.startTime) { + self.duration = + new Date(self.endTime).getTime() - new Date(self.startTime).getTime(); + } else { + self.duration = 0; + } + next(); +}); + +const TimeEntryModel = mongoose.model("TimeEntry", timeEntrySchema); + +export default TimeEntryModel; diff --git a/src/modules/time-entry/time-entry.service.ts b/src/modules/time-entry/time-entry.service.ts new file mode 100644 index 0000000..a9dbde8 --- /dev/null +++ b/src/modules/time-entry/time-entry.service.ts @@ -0,0 +1,273 @@ +import mongoose from "mongoose"; +import TimeEntryModel from "./time-entry.model"; +import UserModel from "@modules/user/user.model"; +import { + ITimeEntry, + CreateTimeEntryInput, + StartTimeEntryInput, + UpdateTimeEntryInput, + TimeEntryOutput, + TimeEntryBase, +} from "./time-entry.types"; +import { ISuccessPayload, IErrorPayload, Mappable } from "src/types"; + +const mapToOutput = (doc: TimeEntryBase & Mappable): TimeEntryOutput => { + return { + id: doc._id.toString(), + userId: doc.userId.toString(), + orgId: doc.orgId.toString(), + projectId: doc.projectId?.toString(), + taskId: doc.taskId?.toString(), + description: doc.description, + startTime: doc.startTime, + endTime: doc.endTime ?? null, + duration: doc.duration, + isBillable: doc.isBillable, + isManual: doc.isManual, + tags: doc.tags?.map((t) => t.toString()) || [], + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +}; + +const TimeEntryService = { + /** + * Start a new timer. + * If an active timer already exists, it stops it first. + */ + startTimeEntry: async ( + userId: string, + orgId: string, + input: StartTimeEntryInput, + ): Promise | IErrorPayload> => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const user = await UserModel.findById(userId).session(session); + if (!user) throw new Error("User not found"); + + // Auto-stop previous timer if exists + if (user.activeTimerId) { + await TimeEntryModel.findByIdAndUpdate( + user.activeTimerId, + { endTime: new Date() }, + { session }, + ); + } + + const startTime = input.startTime + ? new Date(input.startTime) + : new Date(); + + const timeEntry = new TimeEntryModel({ + userId, + orgId, + projectId: input.projectId, + taskId: input.taskId, + description: input.description, + startTime, + endTime: null, + isBillable: input.isBillable ?? true, + isManual: false, + tags: input.tags, + }); + + await timeEntry.save({ session }); + + user.activeTimerId = timeEntry._id as mongoose.Types.ObjectId; + await user.save({ session }); + + await session.commitTransaction(); + + return { + success: true, + data: mapToOutput(timeEntry as unknown as TimeEntryBase & Mappable), + }; + } catch (err) { + if (session.inTransaction()) await session.abortTransaction(); + return { success: false, error: (err as Error).message }; + } finally { + session.endSession(); + } + }, + + /** + * Stop the currently active timer. + */ + stopTimeEntry: async ( + userId: string, + ): Promise | IErrorPayload> => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const user = await UserModel.findById(userId).session(session); + if (!user || !user.activeTimerId) { + throw new Error("No active timer found"); + } + + const timeEntry = await TimeEntryModel.findById( + user.activeTimerId, + ).session(session); + if (!timeEntry) throw new Error("Active time entry not found"); + + timeEntry.endTime = new Date(); + await timeEntry.save({ session }); + + user.activeTimerId = null; + await user.save({ session }); + + await session.commitTransaction(); + + return { + success: true, + data: mapToOutput(timeEntry as unknown as TimeEntryBase & Mappable), + }; + } catch (err) { + if (session.inTransaction()) await session.abortTransaction(); + return { success: false, error: (err as Error).message }; + } finally { + session.endSession(); + } + }, + + /** + * Create a manual time entry (already completed). + */ + createManualEntry: async ( + userId: string, + orgId: string, + input: CreateTimeEntryInput, + ): Promise | IErrorPayload> => { + try { + const timeEntry = new TimeEntryModel({ + userId, + orgId, + projectId: input.projectId, + taskId: input.taskId, + description: input.description, + startTime: new Date(input.startTime), + endTime: input.endTime ? new Date(input.endTime) : new Date(), + isBillable: input.isBillable ?? true, + isManual: true, + tags: input.tags, + }); + + await timeEntry.save(); + + return { + success: true, + data: mapToOutput(timeEntry as unknown as TimeEntryBase & Mappable), + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + + /** + * Get the active time entry for a user. + */ + getActiveEntry: async (userId: string): Promise => { + const user = await UserModel.findById(userId) + .select("activeTimerId") + .lean(); + if (!user?.activeTimerId) return null; + + const entry = await TimeEntryModel.findById(user.activeTimerId).lean(); + return entry + ? mapToOutput(entry as unknown as TimeEntryBase & Mappable) + : null; + }, + + /** + * List time entries with filters. + */ + listEntries: async ( + userId: string, + orgId: string, + filters: { + projectId?: string | undefined; + startDate?: Date | undefined; + endDate?: Date | undefined; + } = {}, + ): Promise => { + const query: mongoose.FilterQuery = { userId, orgId }; + + if (filters.projectId) + query.projectId = new mongoose.Types.ObjectId(filters.projectId); + if (filters.startDate || filters.endDate) { + query.startTime = {}; + if (filters.startDate) query.startTime.$gte = filters.startDate; + if (filters.endDate) query.startTime.$lte = filters.endDate; + } + + const entries = await TimeEntryModel.find(query) + .sort({ startTime: -1 }) + .lean(); + return entries.map((doc) => + mapToOutput(doc as unknown as TimeEntryBase & Mappable), + ); + }, + + getEntryById: async (id: string): Promise => { + return await TimeEntryModel.findById(id); + }, + + updateEntry: async ( + id: string, + input: UpdateTimeEntryInput, + ): Promise | IErrorPayload> => { + try { + const timeEntry = await TimeEntryModel.findById(id); + if (!timeEntry) return { success: false, error: "Time entry not found" }; + + if (input.projectId !== undefined) { + timeEntry.projectId = + input.projectId as unknown as mongoose.Types.ObjectId; + } + if (input.taskId !== undefined) { + timeEntry.taskId = input.taskId as unknown as mongoose.Types.ObjectId; + } + if (input.description !== undefined) { + timeEntry.description = input.description; + } + if (input.startTime !== undefined) { + timeEntry.startTime = new Date(input.startTime); + } + if (input.endTime !== undefined) { + timeEntry.endTime = input.endTime ? new Date(input.endTime) : null; + } + if (input.isBillable !== undefined) { + timeEntry.isBillable = input.isBillable; + } + if (input.tags !== undefined) { + timeEntry.tags = input.tags as unknown as mongoose.Types.ObjectId[]; + } + + await timeEntry.save(); + + return { + success: true, + data: mapToOutput(timeEntry as unknown as TimeEntryBase & Mappable), + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, + + deleteEntry: async ( + id: string, + ): Promise | IErrorPayload> => { + try { + const result = await TimeEntryModel.findByIdAndDelete(id); + if (!result) return { success: false, error: "Time entry not found" }; + + return { success: true, data: null }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }, +}; + +export default TimeEntryService; diff --git a/src/modules/time-entry/time-entry.types.ts b/src/modules/time-entry/time-entry.types.ts new file mode 100644 index 0000000..d3e1573 --- /dev/null +++ b/src/modules/time-entry/time-entry.types.ts @@ -0,0 +1,66 @@ +import mongoose from "mongoose"; + +export interface TimeEntryBase { + userId: mongoose.Types.ObjectId; + orgId: mongoose.Types.ObjectId; + projectId: mongoose.Types.ObjectId; + taskId: mongoose.Types.ObjectId; + description: string; + startTime: Date; + endTime?: Date | null; + duration?: number; + isBillable: boolean; + isManual: boolean; + tags: mongoose.Types.ObjectId[]; +} + +export interface ITimeEntry extends TimeEntryBase, mongoose.Document { + createdAt: Date; + updatedAt: Date; +} + +export interface TimeEntryOutput { + id: string; + userId: string; + orgId: string; + projectId?: string | undefined; + taskId?: string | undefined; + description: string; + startTime: Date; + endTime?: Date | null | undefined; + duration?: number | undefined; + isBillable: boolean; + isManual: boolean; + tags: string[]; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTimeEntryInput { + projectId: string; + taskId: string; + description?: string; + startTime: string; // ISO String + endTime?: string; // ISO String + isBillable?: boolean; + tags?: string[]; +} + +export interface StartTimeEntryInput { + projectId: string; + taskId: string; + description?: string; + startTime?: string; // ISO String, defaults to now + isBillable?: boolean; + tags?: string[]; +} + +export interface UpdateTimeEntryInput { + projectId?: string; + taskId?: string; + description?: string; + startTime?: string; + endTime?: string; + isBillable?: boolean; + tags?: string[]; +} diff --git a/src/modules/time-entry/time-entry.validators.ts b/src/modules/time-entry/time-entry.validators.ts new file mode 100644 index 0000000..993c2bb --- /dev/null +++ b/src/modules/time-entry/time-entry.validators.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { zObjectId } from "@utils/validators"; + +export const createTimeEntrySchema = z + .object({ + projectId: zObjectId, + taskId: zObjectId, + description: z.string().max(500).optional(), + startTime: z.string().datetime(), + endTime: z.string().datetime().optional().nullable(), + isBillable: z.boolean().optional(), + tags: z.array(zObjectId).optional(), + }) + .refine( + (data) => { + if (data.startTime && data.endTime) { + return new Date(data.endTime) > new Date(data.startTime); + } + return true; + }, + { + message: "End time must be after start time", + path: ["endTime"], + }, + ); + +export const startTimeEntrySchema = z.object({ + projectId: zObjectId, + taskId: zObjectId, + description: z.string().max(500).optional(), + startTime: z.string().datetime().optional(), + isBillable: z.boolean().optional(), + tags: z.array(zObjectId).optional(), +}); + +export const updateTimeEntrySchema = z + .object({ + projectId: zObjectId.optional(), + taskId: zObjectId.optional(), + description: z.string().max(500).optional(), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional().nullable(), + isBillable: z.boolean().optional(), + tags: z.array(zObjectId).optional(), + }) + .refine( + (data) => { + if (data.startTime && data.endTime) { + return new Date(data.endTime) > new Date(data.startTime); + } + return true; + }, + { + message: "End time must be after start time", + path: ["endTime"], + }, + ); diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index f8e03f2..8a74fa7 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -9,23 +9,16 @@ const userSchema = new Schema( lastName: { type: String, required: true, trim: true }, email: { type: String, required: true, unique: true, lowercase: true }, password: { type: String, required: true }, - role: { - type: String, - enum: ["owner", "admin", "member", "viewer"], - default: "member", - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - required: function () { - return this.role === "member"; - }, - }, isEmailVerified: { type: Boolean, default: false }, emailVerificationCode: { type: String, default: null }, emailVerificationCodeExpiry: { type: Date, default: null }, passwordResetCode: { type: String, default: null }, passwordResetCodeExpiry: { type: Date, default: null }, + activeTimerId: { + type: Schema.Types.ObjectId, + ref: "TimeEntry", + default: null, + }, }, { timestamps: true }, ); diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 7c55f2e..d5021cb 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -9,6 +9,21 @@ const UserService = { getUserById: async (id: string): Promise => { return await UserModel.findById(id); }, + + createUser: async ( + input: Pick, + ): Promise => { + const { firstName, lastName, email, password } = input; + const user = new UserModel({ + firstName, + lastName, + email, + password, + role: "owner", + }); + await user.save(); + return user; + }, }; export default UserService; diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index 640b429..b96ca73 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -1,16 +1,12 @@ import mongoose from "mongoose"; -export type Role = "owner" | "admin" | "member" | "viewer"; - export interface IUser extends mongoose.Document { _id: mongoose.Types.ObjectId; firstName: string; lastName: string; email: string; password: string; - role: Role; permissions: string[]; - organization: mongoose.Types.ObjectId; createdAt: Date; updatedAt: Date; isEmailVerified: boolean; @@ -18,6 +14,7 @@ export interface IUser extends mongoose.Document { emailVerificationCodeExpiry?: Date | null; passwordResetCode?: string | null; passwordResetCodeExpiry?: Date | null; + activeTimerId?: mongoose.Types.ObjectId | null; generateEmailVerificationCode: () => string; verifyEmailVerificationCode: (code: string) => boolean; clearEmailVerificationData: () => Promise; diff --git a/src/routes/v1.route.ts b/src/routes/v1.route.ts index 4983ef6..a41da93 100644 --- a/src/routes/v1.route.ts +++ b/src/routes/v1.route.ts @@ -1,7 +1,21 @@ import { Router } from "express"; import authRouter from "@modules/auth/routes/auth.v1.routes"; +import organizationRouter from "@modules/organization/routes/organization.v1.routes"; +import membershipRouter from "@modules/membership/routes/membership.v1.routes"; +import tagRouter from "@modules/tag/routes/tag.v1.routes"; +import clientRouter from "@modules/client/routes/client.v1.routes"; +import projectRouter from "@modules/project/routes/project.v1.routes"; +import taskRouter from "@modules/task/routes/task.v1.routes"; +import timeEntryRouter from "@modules/time-entry/routes/time-entry.v1.routes"; const v1Router = Router(); v1Router.use("/auth", authRouter); +v1Router.use("/org", organizationRouter); +v1Router.use("/membership", membershipRouter); +v1Router.use("/tags", tagRouter); +v1Router.use("/clients", clientRouter); +v1Router.use("/projects", projectRouter); +v1Router.use("/tasks", taskRouter); +v1Router.use("/time-entries", timeEntryRouter); export default v1Router; diff --git a/src/server.ts b/src/server.ts index 5c8f70c..8f83dfc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ dotenv.config(); import app, { mountSwagger } from "./app"; import connectDB from "./config/db"; import { PORT } from "@config/env"; +import { logger } from "@config/logger"; import { getTSpec } from "@docs/tspecGenerator"; import { notFound } from "@middlewares/notFound"; @@ -13,7 +14,7 @@ async function start() { app.use("*", notFound); app.listen(PORT, () => { - console.log(`🚀 Server running on http://localhost:${PORT}`); + logger.info({ port: PORT }, "Server running"); }); } diff --git a/src/tests/helpers/seed.ts b/src/tests/helpers/seed.ts index e88a576..ab7f319 100644 --- a/src/tests/helpers/seed.ts +++ b/src/tests/helpers/seed.ts @@ -1,14 +1,46 @@ import UserModel from "@modules/user/user.model"; import OrganizationModel from "@modules/organization/organization.model"; +import MembershipModel from "@modules/membership/membership.model"; import { UserFactory } from "@tests/factories/user.factory"; import { OrganizationFactory } from "@tests/factories/organization.factory"; import { IUser } from "@modules/user/user.types"; import { IOrganization } from "@modules/organization/organization.types"; import { retryOperation } from "@tests/utils"; +import { UserRole } from "@constants"; + +export const seedUserInOrg = async ( + orgId: string, + userOverrides: Partial | undefined = {}, + role: UserRole = "MEMBER", +) => { + return retryOperation(async () => { + const userData = { + ...UserFactory.generate(), + organization: orgId, + isEmailVerified: true, + ...userOverrides, + }; + + const user = new UserModel(userData); + const membership = new MembershipModel({ + orgId: orgId, + userId: user._id, + role: role, + status: "ACTIVE", + joinedAt: new Date(), + }); + + await user.save(); + await membership.save(); + + return { user, membership }; + }); +}; export const seedOneUserWithOrg = async ( userOverrides: Partial | undefined = {}, orgOverrides: Partial | undefined = {}, + role: UserRole = "OWNER", ) => { return retryOperation(async () => { const organization = new OrganizationModel({ @@ -24,10 +56,21 @@ export const seedOneUserWithOrg = async ( }; const user = new UserModel(userData); - organization.owner = user._id; + organization.owner = orgOverrides.owner || user._id; + + const membership = new MembershipModel({ + orgId: organization._id, + userId: user._id, + role: role, + status: "ACTIVE", + joinedAt: new Date(), + }); + await organization.save(); await user.save(); - return { user, organization }; + await membership.save(); + + return { user, organization, membership }; }); }; diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 6f14cc5..3857c35 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,9 +1,13 @@ import { IUser } from "@modules/user/user.types"; +import { IOrganization } from "@modules/organization/organization.types"; +import { MembershipRole } from "@modules/membership/membership.types"; declare global { namespace Express { interface Request { user?: IUser; + userOrg?: IOrganization; + userRole?: MembershipRole; } } } diff --git a/src/types/index.ts b/src/types/index.ts index 46c3180..045ebe5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,3 +15,9 @@ export interface ISuccessPayload { success: boolean; data: T; } + +export interface Mappable { + _id: { toString(): string }; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts index 71c3270..ae2d6e1 100644 --- a/src/utils/AppError.ts +++ b/src/utils/AppError.ts @@ -38,6 +38,10 @@ export default class AppError extends Error { return new AppError(message, 404, details); } + static conflict(message = "Conflict", details?: string) { + return new AppError(message, 409, details); + } + static internal(message = "Internal Server Error", details?: string) { return new AppError(message, 500, details, false); } diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..1f72186 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +/** + * Regex for MongoDB ObjectId + */ +export const objectIdRegex = /^[0-9a-fA-F]{24}$/; + +/** + * Zod schema for validated MongoDB ObjectId strings + */ +export const zObjectId = z.string().regex(objectIdRegex, "Invalid ID format"); diff --git a/tsconfig.json b/tsconfig.json index a016fb2..db2bfb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "@config/*": ["src/config/*"], "@middlewares/*": ["src/middlewares/*"], "@docs/*": ["src/docs/*"], - "@services/*": ["src/services/*"] + "@services/*": ["src/services/*"], + "@constants": ["src/constants/index"] } }, "include": ["src", "tests"] From 0e96b63c99c50d5f3f0db9a02f28b509f2012485 Mon Sep 17 00:00:00 2001 From: Exploit Date: Thu, 5 Mar 2026 06:26:09 +0100 Subject: [PATCH 45/48] Feature/organization endpoints (#17) * feat(org): implement organization creation endpoint with membership module - Add POST /api/v1/org endpoint to create organizations - Create separate membership module with model, service, controller, routes, and docs - Create OWNER membership automatically when organization is created - Add comprehensive integration tests for organization creation - Fix unit tests for email verification and password reset (add save() calls for pre-save hooks) - Update organization service to use membership service - Follow existing codebase patterns and DRY principles * feat(org): add GET /org endpoint to retrieve organization with caller role - Add GET /api/v1/org endpoint that returns organization and user's role - Add getMembershipByUserAndOrg service method - Add getOrganizationWithUserRole service method - Update validateResource middleware to support query parameter validation - Remove console.log from resetPassword unit test - Add proper error handling (404 for not found, 403 for not a member) * feat(org): enforce one organization per user and simplify GET endpoint - Update POST /org to return 409 if user already has an organization - Simplify GET /org to automatically retrieve user's organization (no orgId needed) - Add getUserOrganization service method - Add AppError.conflict() for 409 status code - Update all tests to reflect one organization per user constraint - Refactor getOrganization tests to use separate describe blocks - Update API documentation * feat(org): add GET /org/members endpoint for OWNER/ADMIN - Add requireRole middleware for role-based access control - Implement getOrganizationMembers service method - Add getOrganizationMembers controller handler - Add GET /org/members route protected by OWNER/ADMIN roles - Add OrganizationMember and GetOrganizationMembersOutput types - Update API documentation - Add comprehensive integration tests (13 tests) - Update membership service to populate user data and sort by creation date * refactor * create accept invite endpoint * feat: add security middleware (helmet, rate-limit), cors config and integration tests * feat: implement time entry module, standardize ID mapping, and strengthen type safety * feat: add Phase 4 - Docker, Render config, and structured logging with pino - Add Dockerfile (multi-stage build) and docker-compose.yml (API + MongoDB) - Add .dockerignore and render.yaml (Render IaC blueprint) - Replace console.log/error with pino structured logging - Replace morgan with pino-http for HTTP request logging - Add server-side error logging in errorHandler - Remove debug console.log in membership.service - Uninstall morgan, install pino/pino-http/pino-pretty * ci: optimize CI to run affected tests on PRs and full suite on push From f13bd1c27541dfdf0119886be40ccd6e5b6f01fd Mon Sep 17 00:00:00 2001 From: Exploit Date: Mon, 9 Mar 2026 09:17:15 +0100 Subject: [PATCH 46/48] Feature/organization endpoints (#19) * feat(org): implement organization creation endpoint with membership module - Add POST /api/v1/org endpoint to create organizations - Create separate membership module with model, service, controller, routes, and docs - Create OWNER membership automatically when organization is created - Add comprehensive integration tests for organization creation - Fix unit tests for email verification and password reset (add save() calls for pre-save hooks) - Update organization service to use membership service - Follow existing codebase patterns and DRY principles * feat(org): add GET /org endpoint to retrieve organization with caller role - Add GET /api/v1/org endpoint that returns organization and user's role - Add getMembershipByUserAndOrg service method - Add getOrganizationWithUserRole service method - Update validateResource middleware to support query parameter validation - Remove console.log from resetPassword unit test - Add proper error handling (404 for not found, 403 for not a member) * feat(org): enforce one organization per user and simplify GET endpoint - Update POST /org to return 409 if user already has an organization - Simplify GET /org to automatically retrieve user's organization (no orgId needed) - Add getUserOrganization service method - Add AppError.conflict() for 409 status code - Update all tests to reflect one organization per user constraint - Refactor getOrganization tests to use separate describe blocks - Update API documentation * feat(org): add GET /org/members endpoint for OWNER/ADMIN - Add requireRole middleware for role-based access control - Implement getOrganizationMembers service method - Add getOrganizationMembers controller handler - Add GET /org/members route protected by OWNER/ADMIN roles - Add OrganizationMember and GetOrganizationMembersOutput types - Update API documentation - Add comprehensive integration tests (13 tests) - Update membership service to populate user data and sort by creation date * refactor * create accept invite endpoint * feat: add security middleware (helmet, rate-limit), cors config and integration tests * feat: implement time entry module, standardize ID mapping, and strengthen type safety * feat: add Phase 4 - Docker, Render config, and structured logging with pino - Add Dockerfile (multi-stage build) and docker-compose.yml (API + MongoDB) - Add .dockerignore and render.yaml (Render IaC blueprint) - Replace console.log/error with pino structured logging - Replace morgan with pino-http for HTTP request logging - Add server-side error logging in errorHandler - Remove debug console.log in membership.service - Uninstall morgan, install pino/pino-http/pino-pretty * ci: optimize CI to run affected tests on PRs and full suite on push * Update render.yaml * Install pino pretty as a dep --- package-lock.json | 205 +++++++++++++++++++++++++++++----------------- package.json | 3 +- render.yaml | 2 +- 3 files changed, 135 insertions(+), 75 deletions(-) diff --git a/package-lock.json b/package-lock.json index c83f90c..a907f39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "mongoose": "^8.5.0", "pino": "^10.3.1", "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", @@ -1330,13 +1331,13 @@ } }, "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2372,13 +2373,13 @@ } }, "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==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2763,9 +2764,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3063,23 +3064,23 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -3095,12 +3096,41 @@ "ms": "2.0.0" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3701,9 +3731,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -4156,39 +4186,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -4202,12 +4232,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -5076,9 +5106,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -5499,13 +5529,13 @@ } }, "node_modules/jest-config/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5843,13 +5873,13 @@ } }, "node_modules/jest-runtime/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6432,9 +6462,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7366,12 +7396,12 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -7427,16 +7457,45 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -8553,9 +8612,9 @@ } }, "node_modules/tspec/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" diff --git a/package.json b/package.json index 632e76d..befb1fe 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "tsconfig-paths": "^4.2.0", "tspec": "^0.1.116", "zeptomail": "^6.2.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "pino-pretty": "^13.1.3" }, "devDependencies": { "@eslint/js": "^9.37.0", diff --git a/render.yaml b/render.yaml index 39a529a..d96320d 100644 --- a/render.yaml +++ b/render.yaml @@ -4,7 +4,7 @@ services: runtime: docker plan: free branch: main - healthCheckPath: /health + healthCheckPath: /api/health envVars: - key: NODE_ENV value: production From ffdc7654d362f5107e06ecbb47b84fbe19b22b7f Mon Sep 17 00:00:00 2001 From: exploitenomah Date: Mon, 9 Mar 2026 09:49:29 +0100 Subject: [PATCH 47/48] Remove pino pretty from dev deps --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index befb1fe..08529ef 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "husky": "^8.0.0", "jest": "^30.2.0", "mongodb-memory-server": "^10.2.3", - "pino-pretty": "^13.1.3", "prettier": "^3.7.4", "supertest": "^7.1.4", "ts-jest": "^29.4.5", From 1324fc160d02282fd185d5ef582696a39c5e38be Mon Sep 17 00:00:00 2001 From: Exploit Date: Mon, 9 Mar 2026 19:45:15 +0100 Subject: [PATCH 48/48] Feature/organization endpoints (#22) * feat(org): implement organization creation endpoint with membership module - Add POST /api/v1/org endpoint to create organizations - Create separate membership module with model, service, controller, routes, and docs - Create OWNER membership automatically when organization is created - Add comprehensive integration tests for organization creation - Fix unit tests for email verification and password reset (add save() calls for pre-save hooks) - Update organization service to use membership service - Follow existing codebase patterns and DRY principles * feat(org): add GET /org endpoint to retrieve organization with caller role - Add GET /api/v1/org endpoint that returns organization and user's role - Add getMembershipByUserAndOrg service method - Add getOrganizationWithUserRole service method - Update validateResource middleware to support query parameter validation - Remove console.log from resetPassword unit test - Add proper error handling (404 for not found, 403 for not a member) * feat(org): enforce one organization per user and simplify GET endpoint - Update POST /org to return 409 if user already has an organization - Simplify GET /org to automatically retrieve user's organization (no orgId needed) - Add getUserOrganization service method - Add AppError.conflict() for 409 status code - Update all tests to reflect one organization per user constraint - Refactor getOrganization tests to use separate describe blocks - Update API documentation * feat(org): add GET /org/members endpoint for OWNER/ADMIN - Add requireRole middleware for role-based access control - Implement getOrganizationMembers service method - Add getOrganizationMembers controller handler - Add GET /org/members route protected by OWNER/ADMIN roles - Add OrganizationMember and GetOrganizationMembersOutput types - Update API documentation - Add comprehensive integration tests (13 tests) - Update membership service to populate user data and sort by creation date * refactor * create accept invite endpoint * feat: add security middleware (helmet, rate-limit), cors config and integration tests * feat: implement time entry module, standardize ID mapping, and strengthen type safety * feat: add Phase 4 - Docker, Render config, and structured logging with pino - Add Dockerfile (multi-stage build) and docker-compose.yml (API + MongoDB) - Add .dockerignore and render.yaml (Render IaC blueprint) - Replace console.log/error with pino structured logging - Replace morgan with pino-http for HTTP request logging - Add server-side error logging in errorHandler - Remove debug console.log in membership.service - Uninstall morgan, install pino/pino-http/pino-pretty * ci: optimize CI to run affected tests on PRs and full suite on push * Update render.yaml * Install pino pretty as a dep * Fix docs build error --- Dockerfile | 1 + package.json | 5 +++-- src/docs/generateDoc.ts | 31 +++++++++++++++++++++++++++++++ src/docs/tspecGenerator.ts | 24 +++++++++++++++++++++--- 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 src/docs/generateDoc.ts diff --git a/Dockerfile b/Dockerfile index 24a4a22..e059342 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force # Copy compiled output from builder COPY --from=builder /app/dist ./dist +COPY --from=builder /app/openapi.json ./openapi.json # Run as non-root for security USER node diff --git a/package.json b/package.json index 08529ef..f4116bd 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "type": "commonjs", "scripts": { "dev": "ts-node-dev --respawn --transpile-only -r tsconfig-paths/register src/server.ts", - "build": "tsc && tsc-alias", + "build": "npm run build:docs && tsc && tsc-alias", + "build:docs": "ts-node -r tsconfig-paths/register src/docs/generateDoc.ts", "start": "node dist/server.js", "lint": "eslint . --ext .ts --fix", "lint:check": "eslint . --ext .ts", @@ -33,12 +34,12 @@ "pino-http": "^11.0.0", "swagger-ui-express": "^5.0.1", "tsconfig-paths": "^4.2.0", - "tspec": "^0.1.116", "zeptomail": "^6.2.1", "zod": "^3.23.8", "pino-pretty": "^13.1.3" }, "devDependencies": { + "tspec": "^0.1.116", "@eslint/js": "^9.37.0", "@types/cookie": "^0.6.0", "@types/cookie-signature": "^1.1.2", diff --git a/src/docs/generateDoc.ts b/src/docs/generateDoc.ts new file mode 100644 index 0000000..baef372 --- /dev/null +++ b/src/docs/generateDoc.ts @@ -0,0 +1,31 @@ +import { generateTspec, Tspec } from "tspec"; +import fs from "fs"; +import path from "path"; + +const options: Tspec.GenerateParams = { + specPathGlobs: ["src/**/*.ts"], + tsconfigPath: "./tsconfig.json", + outputPath: "openapi.json", + specVersion: 3, + openapi: { + title: "Timesheets By Exploit", + version: "1.0.0", + description: + "This is the official documentation of the Timesheets By Exploit API", + }, + debug: false, + ignoreErrors: true, +}; + +async function generate() { + console.log("Generating OpenAPI specification..."); + const spec = await generateTspec(options); + const outputPath = path.join(process.cwd(), "openapi.json"); + fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2)); + console.log(`OpenAPI specification generated at ${outputPath}`); +} + +generate().catch((err) => { + console.error("Failed to generate OpenAPI specification:", err); + process.exit(1); +}); diff --git a/src/docs/tspecGenerator.ts b/src/docs/tspecGenerator.ts index 1fa2053..6a28148 100644 --- a/src/docs/tspecGenerator.ts +++ b/src/docs/tspecGenerator.ts @@ -1,6 +1,8 @@ -import { generateTspec, Tspec } from "tspec"; +import fs from "fs"; +import path from "path"; +import { Tspec } from "tspec"; -const options: Tspec.GenerateParams = { +const options = { specPathGlobs: ["src/**/*.ts"], tsconfigPath: "./tsconfig.json", outputPath: "openapi.json", @@ -16,5 +18,21 @@ const options: Tspec.GenerateParams = { }; export async function getTSpec() { - return await generateTspec(options); + if ( + process.env.NODE_ENV === "production" || + process.env.TSPEC_STATIC === "true" + ) { + try { + const specPath = path.join(process.cwd(), "openapi.json"); + const spec = JSON.parse(fs.readFileSync(specPath, "utf8")); + return spec; + } catch (error) { + console.error("Failed to load pre-generated OpenAPI spec:", error); + throw error; + } + } + + // Dynamic import to avoid runtime dependency in production + const { generateTspec } = await import("tspec"); + return await generateTspec(options as Tspec.GenerateParams | undefined); }