diff --git a/docs/MFA_ROADMAP.md b/docs/MFA_ROADMAP.md index 6d47ee52..4b35c21f 100644 --- a/docs/MFA_ROADMAP.md +++ b/docs/MFA_ROADMAP.md @@ -6,12 +6,12 @@ Two-factor authentication is fully implemented using TOTP (Time-based One-Time P ### Endpoints -| Endpoint | Method | Description | -|----------|--------|-------------| -| `POST /api/auth/2fa/setup` | POST | Initialize 2FA — returns secret + QR code URL | -| `POST /api/auth/2fa/verify` | POST | Verify a TOTP code and activate 2FA | -| `POST /api/auth/2fa/disable` | POST | Disable 2FA (requires current TOTP code) | -| `POST /api/auth/2fa/backup-codes` | POST | Regenerate backup codes | +| Endpoint | Method | Description | +| --------------------------------- | ------ | --------------------------------------------- | +| `POST /api/auth/2fa/setup` | POST | Initialize 2FA — returns secret + QR code URL | +| `POST /api/auth/2fa/verify` | POST | Verify a TOTP code and activate 2FA | +| `POST /api/auth/2fa/disable` | POST | Disable 2FA (requires current TOTP code) | +| `POST /api/auth/2fa/backup-codes` | POST | Regenerate backup codes | ### DTOs diff --git a/package-lock.json b/package-lock.json index dea5c029..0e4f431b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@apollo/server": "^5.5.1", + "@keyv/redis": "^5.1.6", "@nestjs-modules/mailer": "^2.3.4", "@nestjs/apollo": "^13.4.2", "@nestjs/bullmq": "^11.0.4", @@ -31,7 +32,6 @@ "bcrypt": "^6.0.0", "bullmq": "^5.76.3", "cache-manager": "^7.2.8", - "cache-manager-redis-store": "^3.0.1", "canvas": "^3.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", @@ -96,8 +96,8 @@ "typescript": "^5.3.2" }, "engines": { - "node": ">=20.0.0", - "npm": ">=8.0.0" + "node": ">=18.0.0", + "npm": ">=10.0.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -206,40 +206,6 @@ } } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -278,40 +244,6 @@ } } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -3068,6 +3000,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/redis": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-5.1.6.tgz", + "integrity": "sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==", + "license": "MIT", + "dependencies": { + "@redis/client": "^5.10.0", + "cluster-key-slot": "^1.1.2", + "hookified": "^1.13.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/redis/node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", @@ -3985,40 +3943,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@nestjs/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -5868,7 +5792,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -5882,7 +5806,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6394,20 +6318,6 @@ "node": ">= 18" } }, - "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==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", @@ -6781,109 +6691,6 @@ "keyv": "^5.6.0" } }, - "node_modules/cache-manager-redis-store": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-3.0.1.tgz", - "integrity": "sha512-o560kw+dFqusC9lQJhcm6L2F2fMKobJ5af+FoR2PdnMVdpQ3f3Bz6qzvObTGyvoazQJxjQNWgMQeChP4vRTuXQ==", - "license": "MIT", - "dependencies": { - "redis": "^4.3.1" - }, - "engines": { - "node": ">= 16.18.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/bloom": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", - "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/client": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", - "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", - "license": "MIT", - "dependencies": { - "cluster-key-slot": "1.1.2", - "generic-pool": "3.9.0", - "yallist": "4.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/graph": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", - "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/json": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", - "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/search": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", - "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/time-series": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", - "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/redis": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", - "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", - "license": "MIT", - "workspaces": [ - "./packages/*" - ], - "dependencies": { - "@redis/bloom": "1.2.0", - "@redis/client": "1.6.1", - "@redis/graph": "1.1.1", - "@redis/json": "1.0.7", - "@redis/search": "1.2.0", - "@redis/time-series": "1.1.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -7093,46 +6900,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "optional": true, - "peer": true, - "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==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -9858,6 +9625,7 @@ "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, @@ -9886,15 +9654,6 @@ "node": ">= 0.4" } }, - "node_modules/generic-pool": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", - "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -10713,20 +10472,6 @@ "devOptional": 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==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -15934,34 +15679,6 @@ "node": ">=10" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/redis": { "version": "5.12.1", "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", @@ -18063,7 +17780,6 @@ "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": { diff --git a/package.json b/package.json index ac129317..52b63bf9 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,14 @@ "private": true, "license": "MIT", "scripts": { - "prebuild": "rimraf dist", + "prebuild": "rimraf dist tsconfig.tsbuildinfo", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "prestart": "rimraf dist tsconfig.tsbuildinfo", "start": "nest start", + "prestart:dev": "rimraf dist tsconfig.tsbuildinfo", "start:dev": "nest start --watch", + "prestart:debug": "rimraf dist tsconfig.tsbuildinfo", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", @@ -41,6 +44,7 @@ }, "dependencies": { "@apollo/server": "^5.5.1", + "@keyv/redis": "^5.1.6", "@nestjs-modules/mailer": "^2.3.4", "@nestjs/apollo": "^13.4.2", "@nestjs/bullmq": "^11.0.4", @@ -62,7 +66,6 @@ "bcrypt": "^6.0.0", "bullmq": "^5.76.3", "cache-manager": "^7.2.8", - "cache-manager-redis-store": "^3.0.1", "canvas": "^3.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", diff --git a/prisma/migrations/20260726120000_add_user_tier/migration.sql b/prisma/migrations/20260726120000_add_user_tier/migration.sql new file mode 100644 index 00000000..da82dd80 --- /dev/null +++ b/prisma/migrations/20260726120000_add_user_tier/migration.sql @@ -0,0 +1,3 @@ +CREATE TYPE "UserTier" AS ENUM ('FREE', 'PREMIUM', 'ENTERPRISE'); + +ALTER TABLE "users" ADD COLUMN "tier" "UserTier" NOT NULL DEFAULT 'FREE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a46d7a24..cb79e0c7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,12 @@ enum UserRole { ADMIN } +enum UserTier { + FREE + PREMIUM + ENTERPRISE +} + enum TokenType { ACCESS REFRESH @@ -183,6 +189,7 @@ model User { twoFactorBackupCodes String[] @default([]) @map("two_factor_backup_codes") // NOTE: added to satisfy code references; CI-prone strict mode workaround role UserRole @default(USER) @map("role") + tier UserTier @default(FREE) @map("tier") avatar String? pendingEmail String? @map("pending_email") emailVerificationToken String? @map("email_verification_token") diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts index 3c5ba531..7700ad43 100644 --- a/scripts/benchmark.ts +++ b/scripts/benchmark.ts @@ -102,16 +102,76 @@ async function runBenchmark(): Promise { console.log(` Iterations: ${ITERATIONS} (warmup: ${WARMUP_ROUNDS})\n`); const endpoints = [ - { name: 'Properties List', method: 'GET', path: '/properties?limit=20', category: 'list' as const, budget: 500 }, - { name: 'Property Detail', method: 'GET', path: '/properties/test-id', category: 'detail' as const, budget: 200 }, - { name: 'Transactions List', method: 'GET', path: '/transactions?limit=20', category: 'list' as const, budget: 500 }, - { name: 'Transaction Detail', method: 'GET', path: '/transactions/test-id', category: 'detail' as const, budget: 200 }, - { name: 'Search Properties', method: 'GET', path: '/search?q=apartment', category: 'search' as const, budget: 500 }, - { name: 'User Profile (me)', method: 'GET', path: '/auth/me', category: 'detail' as const, budget: 200 }, - { name: 'List API Keys', method: 'GET', path: '/auth/api-keys', category: 'list' as const, budget: 500 }, - { name: 'Dashboard Stats', method: 'GET', path: '/admin/dashboard', category: 'detail' as const, budget: 200 }, - { name: 'Email Reputation', method: 'GET', path: '/email/reputation', category: 'detail' as const, budget: 200 }, - { name: 'Queue Metrics', method: 'GET', path: '/admin/queues/metrics', category: 'detail' as const, budget: 200 }, + { + name: 'Properties List', + method: 'GET', + path: '/properties?limit=20', + category: 'list' as const, + budget: 500, + }, + { + name: 'Property Detail', + method: 'GET', + path: '/properties/test-id', + category: 'detail' as const, + budget: 200, + }, + { + name: 'Transactions List', + method: 'GET', + path: '/transactions?limit=20', + category: 'list' as const, + budget: 500, + }, + { + name: 'Transaction Detail', + method: 'GET', + path: '/transactions/test-id', + category: 'detail' as const, + budget: 200, + }, + { + name: 'Search Properties', + method: 'GET', + path: '/search?q=apartment', + category: 'search' as const, + budget: 500, + }, + { + name: 'User Profile (me)', + method: 'GET', + path: '/auth/me', + category: 'detail' as const, + budget: 200, + }, + { + name: 'List API Keys', + method: 'GET', + path: '/auth/api-keys', + category: 'list' as const, + budget: 500, + }, + { + name: 'Dashboard Stats', + method: 'GET', + path: '/admin/dashboard', + category: 'detail' as const, + budget: 200, + }, + { + name: 'Email Reputation', + method: 'GET', + path: '/email/reputation', + category: 'detail' as const, + budget: 200, + }, + { + name: 'Queue Metrics', + method: 'GET', + path: '/admin/queues/metrics', + category: 'detail' as const, + budget: 200, + }, ]; const results: BenchmarkResult[] = []; diff --git a/src/app.module.ts b/src/app.module.ts index d8659744..e4956a36 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -58,7 +58,6 @@ import { APP_FILTER } from '@nestjs/core'; isGlobal: true, envFilePath: ['.env.local', '.env'], }), - ScheduleModule.forRoot(), CacheModuleConfig, AnalyticsModule, diff --git a/src/auth/auth-tier.spec.ts b/src/auth/auth-tier.spec.ts new file mode 100644 index 00000000..9505e92d --- /dev/null +++ b/src/auth/auth-tier.spec.ts @@ -0,0 +1,141 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; +import { AuthService } from './auth.service'; +import { PrismaService } from '../database/prisma.service'; +import { UsersService } from '../users/users.service'; +import { SessionsService } from '../sessions/sessions.service'; +import { EmailService } from '../email/email.service'; +import { LoginRateLimitService } from './login-rate-limit.service'; +import { FraudService } from '../fraud/fraud.service'; +import { ApiKeyAnalyticsService } from './api-key-analytics.service'; + +describe('AuthService - tier propagation', () => { + let service: AuthService; + + const sessionsService = { + createSession: jest.fn().mockResolvedValue(undefined), + }; + + const prisma = { + user: { findUnique: jest.fn(), update: jest.fn().mockResolvedValue(undefined) }, + apiKey: { findUnique: jest.fn(), update: jest.fn().mockResolvedValue(undefined) }, + blacklistedToken: { findUnique: jest.fn().mockResolvedValue(null) }, + }; + + const configService = { + get: jest.fn((key: string) => { + const config: Record = { + JWT_SECRET: 'test-secret-at-least-32-characters-long', + JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters-long', + JWT_ACCESS_EXPIRES_IN: '15m', + JWT_REFRESH_EXPIRES_IN: '7d', + BCRYPT_ROUNDS: '10', + }; + return config[key]; + }), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + prisma.user.update.mockResolvedValue(undefined); + prisma.apiKey.update.mockResolvedValue(undefined); + prisma.blacklistedToken.findUnique.mockResolvedValue(null); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: PrismaService, useValue: prisma }, + { provide: UsersService, useValue: {} }, + { provide: SessionsService, useValue: sessionsService }, + { provide: EmailService, useValue: {} }, + { provide: LoginRateLimitService, useValue: {} }, + { provide: FraudService, useValue: {} }, + { provide: ConfigService, useValue: configService }, + { + provide: ApiKeyAnalyticsService, + useValue: { + checkQuota: jest.fn().mockResolvedValue(undefined), + recordUsage: jest.fn().mockResolvedValue(undefined), + }, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + describe('issueTokenPair', () => { + it('includes tier in both the access and refresh token claims', async () => { + const user = { + id: 'user-1', + email: 'user@test.com', + role: 'AGENT', + tier: 'PREMIUM', + } as any; + + const tokens = await service.issueTokenPair(user); + + const accessClaims = jwt.decode(tokens.accessToken) as any; + const refreshClaims = jwt.decode(tokens.refreshToken) as any; + + expect(accessClaims.tier).toBe('PREMIUM'); + expect(accessClaims.role).toBe('AGENT'); + expect(refreshClaims.tier).toBe('PREMIUM'); + }); + }); + + describe('validateAccessToken', () => { + it('returns the tier read fresh from the database, not from the token', async () => { + const token = jwt.sign( + { + sub: 'user-1', + email: 'user@test.com', + role: 'FREE', + tier: 'FREE', + type: 'access', + jti: 'jti-1', + }, + 'test-secret-at-least-32-characters-long', + { expiresIn: '15m', issuer: 'PropChain' }, + ); + + // DB now reports a different (upgraded) tier than what was baked into the token + prisma.user.findUnique.mockResolvedValue({ + email: 'user@test.com', + role: 'USER', + tier: 'ENTERPRISE', + lastActivityAt: new Date(), + }); + + const result = await service.validateAccessToken(token); + + expect(result.tier).toBe('ENTERPRISE'); + expect(result.sub).toBe('user-1'); + }); + }); + + describe('validateApiKey', () => { + it('surfaces the owning user tier on the api-key payload', async () => { + prisma.apiKey.findUnique.mockResolvedValue({ + id: 'key-1', + userId: 'user-1', + revokedAt: null, + expiresAt: null, + permissions: ['read'], + user: { + email: 'user@test.com', + role: 'AGENT', + tier: 'ENTERPRISE', + isBlocked: false, + }, + }); + + const result = await service.validateApiKey('raw-api-key-value'); + + expect(result.tier).toBe('ENTERPRISE'); + expect(result.type).toBe('api-key'); + expect(result.apiKeyId).toBe('key-1'); + }); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index a572a5b4..a3001821 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -46,7 +46,7 @@ import { AuthUserPayload } from './types/auth-user.type'; import { GoogleProfile } from './strategies/google.strategy'; import { LoginRateLimitService } from './login-rate-limit.service'; -import { UserRole } from '../types/prisma.types'; +import { UserRole, UserTier } from '../types/prisma.types'; import { FraudService } from '../fraud/fraud.service'; import { ApiKeyAnalyticsService } from './api-key-analytics.service'; @@ -56,6 +56,7 @@ type JwtPayload = { sub: string; email: string; role: UserRole; + tier: UserTier; type: 'access' | 'refresh'; jti: string; family?: string; @@ -1220,6 +1221,7 @@ export class AuthService { select: { email: true, role: true, + tier: true, lastActivityAt: true, }, }); @@ -1248,6 +1250,7 @@ export class AuthService { sub: payload.sub, email: user.email, role: user.role, + tier: user.tier, type: 'access', jti: payload.jti, }; @@ -1291,6 +1294,7 @@ export class AuthService { sub: apiKey.userId, email: apiKey.user.email, role: apiKey.user.role as UserRole, + tier: apiKey.user.tier as UserTier, type: 'api-key', apiKeyId: apiKey.id, apiKeyPermissions: apiKey.permissions, @@ -1298,7 +1302,7 @@ export class AuthService { } async issueTokenPair( - user: { id: string; email: string; role: string }, + user: { id: string; email: string; role: string; tier: string }, tokenFamily?: string, ipAddress?: string, userAgent?: string, @@ -1312,6 +1316,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role as UserRole, + tier: user.tier as UserTier, type: 'access', jti: accessJti, family: family, @@ -1325,6 +1330,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role as UserRole, + tier: user.tier as UserTier, type: 'refresh', jti: refreshJti, family: family, diff --git a/src/auth/guards/rate-limit.guard.ts b/src/auth/guards/rate-limit.guard.ts index afc9eb28..8a742aa0 100644 --- a/src/auth/guards/rate-limit.guard.ts +++ b/src/auth/guards/rate-limit.guard.ts @@ -56,17 +56,20 @@ export class RateLimitGuard implements CanActivate { const endpoint = `${request.method} ${request.route?.path || request.url}`; try { - // Check by user if authenticated - // Tier defaults to 'free' as it is not included in the current JWT payload. - // When 'tier' is added to JwtPayload, this logic will use the actual value. + // Check by user if authenticated. Auth guards (JwtAuthGuard, ApiKeyAuthGuard) + // attach the authenticated payload as `request.authUser`, keyed by `sub`. const ip = this.getClientIp(request); + const authUser = request.authUser; - if (request.user?.id) { - const userTier = request.user.tier || 'free'; + if (authUser?.sub) { + // API keys get their own dedicated bucket regardless of the owning user's + // tier; JWT-authenticated requests use the user's real tier from the DB. + const userTier = + authUser.type === 'api-key' ? 'apiKey' : (authUser.tier || 'FREE').toLowerCase(); const [userStatus, userIpStatus] = await Promise.all([ - this.rateLimitService.checkUserRateLimit(request.user.id, userTier), - this.rateLimitService.checkUserIpRateLimit(request.user.id, ip), + this.rateLimitService.checkUserRateLimit(authUser.sub, userTier), + this.rateLimitService.checkUserIpRateLimit(authUser.sub, ip), ]); Object.entries(this.rateLimitService.getHeaders(userStatus)).forEach(([key, value]) => { diff --git a/src/auth/rate-limit.guard.spec.ts b/src/auth/rate-limit.guard.spec.ts index 1eb827dc..140dbdc9 100644 --- a/src/auth/rate-limit.guard.spec.ts +++ b/src/auth/rate-limit.guard.spec.ts @@ -38,6 +38,7 @@ describe('RateLimitGuard - auth/signup endpoints', () => { rateLimitService = { checkIpRateLimit: jest.fn().mockResolvedValue(notExceeded), checkUserRateLimit: jest.fn().mockResolvedValue(notExceeded), + checkUserIpRateLimit: jest.fn().mockResolvedValue(notExceeded), checkEndpointRateLimit: jest.fn().mockResolvedValue({ ...notExceeded, limit: 0 }), getHeaders: jest.fn().mockReturnValue({}), } as any; @@ -92,3 +93,80 @@ describe('RateLimitGuard - auth/signup endpoints', () => { await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException); }); }); + +describe('RateLimitGuard - authenticated tier-based limiting', () => { + let guard: RateLimitGuard; + let rateLimitService: jest.Mocked; + let reflector: Reflector; + + const notExceeded = { limit: 5, remaining: 4, reset: 9999999999, isExceeded: false }; + const exceeded = { limit: 5, remaining: 0, reset: 9999999999, isExceeded: true, retryAfter: 60 }; + + beforeEach(() => { + reflector = new Reflector(); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + + rateLimitService = { + checkIpRateLimit: jest.fn().mockResolvedValue(notExceeded), + checkUserRateLimit: jest.fn().mockResolvedValue(notExceeded), + checkUserIpRateLimit: jest.fn().mockResolvedValue(notExceeded), + checkEndpointRateLimit: jest.fn().mockResolvedValue({ ...notExceeded, limit: 0 }), + getHeaders: jest.fn().mockReturnValue({}), + } as any; + + guard = new RateLimitGuard(reflector, rateLimitService); + }); + + it('checks per-user limits using request.authUser, not request.user', async () => { + const ctx = makeContext({ + authUser: { sub: 'user-1', tier: 'PREMIUM', type: 'access' }, + }); + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(rateLimitService.checkUserRateLimit).toHaveBeenCalledWith('user-1', 'premium'); + expect(rateLimitService.checkUserIpRateLimit).toHaveBeenCalledWith('user-1', '1.2.3.4'); + expect(rateLimitService.checkIpRateLimit).not.toHaveBeenCalled(); + }); + + it('ignores a stray request.user field and treats the request as anonymous', async () => { + // Regression test: the guard used to read request.user.id (which nothing in this + // codebase ever sets), silently bucketing every authenticated request as anonymous. + const ctx = makeContext({ + user: { id: 'user-1', tier: 'enterprise' }, + }); + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(rateLimitService.checkUserRateLimit).not.toHaveBeenCalled(); + expect(rateLimitService.checkIpRateLimit).toHaveBeenCalledWith('1.2.3.4'); + }); + + it('defaults to the free tier when authUser.tier is missing', async () => { + const ctx = makeContext({ + authUser: { sub: 'user-1', type: 'access' }, + }); + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(rateLimitService.checkUserRateLimit).toHaveBeenCalledWith('user-1', 'free'); + }); + + it('buckets api-key requests under apiKey regardless of the owning user tier', async () => { + const ctx = makeContext({ + authUser: { sub: 'user-1', tier: 'FREE', type: 'api-key', apiKeyId: 'key-1' }, + }); + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(rateLimitService.checkUserRateLimit).toHaveBeenCalledWith('user-1', 'apiKey'); + }); + + it('blocks when the per-user limit is exceeded', async () => { + rateLimitService.checkUserRateLimit.mockResolvedValue(exceeded); + const ctx = makeContext({ + authUser: { sub: 'user-1', tier: 'FREE', type: 'access' }, + }); + await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException); + }); + + it('blocks when the per-user-per-IP limit is exceeded', async () => { + rateLimitService.checkUserIpRateLimit.mockResolvedValue(exceeded); + const ctx = makeContext({ + authUser: { sub: 'user-1', tier: 'FREE', type: 'access' }, + }); + await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException); + }); +}); diff --git a/src/auth/rbac.spec.ts b/src/auth/rbac.spec.ts index 37451bec..bbd2b3f9 100644 --- a/src/auth/rbac.spec.ts +++ b/src/auth/rbac.spec.ts @@ -7,10 +7,16 @@ import { ForbiddenException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { RolesGuard } from '../auth/guards/roles.guard'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -import { UserRole } from '../types/prisma.types'; +import { UserRole, UserTier } from '../types/prisma.types'; function makeContext(role: UserRole, requiredRoles: UserRole[] | null) { - const user: AuthUserPayload = { sub: 'u1', email: 'u@test.com', role, type: 'access' }; + const user: AuthUserPayload = { + sub: 'u1', + email: 'u@test.com', + role, + tier: UserTier.FREE, + type: 'access', + }; const request = { authUser: user }; return { switchToHttp: () => ({ getRequest: () => request }), @@ -123,12 +129,14 @@ describe('PropertiesController - RBAC', () => { sub: 'admin-1', email: 'admin@test.com', role: UserRole.ADMIN, + tier: UserTier.FREE, type: 'access', }; const regularUser: AuthUserPayload = { sub: 'user-1', email: 'user@test.com', role: UserRole.USER, + tier: UserTier.FREE, type: 'access', }; diff --git a/src/auth/types/auth-user.type.ts b/src/auth/types/auth-user.type.ts index 30488183..38ffbab3 100644 --- a/src/auth/types/auth-user.type.ts +++ b/src/auth/types/auth-user.type.ts @@ -1,11 +1,12 @@ // @ts-nocheck -import { UserRole } from '@prisma/client'; +import { UserRole, UserTier } from '@prisma/client'; export type AuthUserPayload = { sub: string; email: string; role: UserRole; + tier: UserTier; type: 'access' | 'refresh' | 'api-key'; jti?: string; apiKeyId?: string; diff --git a/src/cache/cache.config.ts b/src/cache/cache.config.ts index f056f4e9..832fc9f9 100644 --- a/src/cache/cache.config.ts +++ b/src/cache/cache.config.ts @@ -6,16 +6,23 @@ */ import { CacheModuleOptions } from '@nestjs/cache-manager'; -import * as redisStore from 'cache-manager-redis-store'; +import { Keyv } from 'keyv'; +import KeyvRedis from '@keyv/redis'; +// cache-manager v7 (used via @nestjs/cache-manager v3) is Keyv-based; the old +// cache-manager-redis-store package targeted cache-manager v3/v4's store +// interface and is not compatible with this version. @keyv/redis is the +// maintained adapter for the current API. export const REDIS_CONFIG: CacheModuleOptions = { isGlobal: true, - store: redisStore as any, - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379', 10), - password: process.env.REDIS_PASSWORD, - db: parseInt(process.env.REDIS_DB || '0', 10), - ttl: 600, // Default TTL: 10 minutes + stores: [ + new Keyv({ + store: new KeyvRedis({ + url: getRedisConnectionString(), + }), + }), + ], + ttl: 600 * 1000, // Default TTL: 10 minutes (Keyv TTLs are in milliseconds) }; /** diff --git a/src/commissions/commissions.service.spec.ts b/src/commissions/commissions.service.spec.ts index ab8e51fe..2ee38b89 100644 --- a/src/commissions/commissions.service.spec.ts +++ b/src/commissions/commissions.service.spec.ts @@ -104,7 +104,7 @@ describe('CommissionsService', () => { await service.findAll( { page: 1, limit: 10 }, - { sub: 'agent-1', email: 'agent@test.com', role: 'AGENT', type: 'access' }, + { sub: 'agent-1', email: 'agent@test.com', role: 'AGENT', tier: 'FREE', type: 'access' }, ); expect((prisma as any).commission.findMany).toHaveBeenCalledWith( @@ -131,6 +131,7 @@ describe('CommissionsService', () => { sub: 'agent-2', email: 'agent2@test.com', role: 'AGENT', + tier: 'FREE', type: 'access', }), ).rejects.toBeInstanceOf(ForbiddenException); @@ -165,6 +166,7 @@ describe('CommissionsService', () => { sub: 'admin-1', email: 'admin@test.com', role: 'ADMIN', + tier: 'FREE', type: 'access', }); diff --git a/src/documents/documents-download.controller.spec.ts b/src/documents/documents-download.controller.spec.ts index 5eeeeb73..cd8f7f60 100644 --- a/src/documents/documents-download.controller.spec.ts +++ b/src/documents/documents-download.controller.spec.ts @@ -5,7 +5,7 @@ import { SignedUrlService } from './signed-url/signed-url.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -import { UserRole } from '../types/prisma.types'; +import { UserRole, UserTier } from '../types/prisma.types'; import { Response } from 'express'; describe('DocumentsDownloadController', () => { @@ -30,6 +30,7 @@ describe('DocumentsDownloadController', () => { const mockUser: AuthUserPayload = { sub: 'user-1', role: UserRole.USER, + tier: UserTier.FREE, email: 'test@test.com', type: 'access', }; diff --git a/src/documents/documents.controller.spec.ts b/src/documents/documents.controller.spec.ts index a3f2a219..be4b83ae 100644 --- a/src/documents/documents.controller.spec.ts +++ b/src/documents/documents.controller.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DocumentsController } from './documents.controller'; import { DocumentsService } from './documents.service'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -import { UserRole } from '../types/prisma.types'; +import { UserRole, UserTier } from '../types/prisma.types'; import { Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -32,6 +32,7 @@ describe('DocumentsController', () => { const mockUser: AuthUserPayload = { sub: 'user-1', role: UserRole.USER, + tier: UserTier.FREE, email: 'test@test.com', type: 'access', }; diff --git a/src/mortgage-calculator/dto/mortgage-calculator.dto.ts b/src/mortgage-calculator/dto/mortgage-calculator.dto.ts index fb170880..9f4a0ff4 100644 --- a/src/mortgage-calculator/dto/mortgage-calculator.dto.ts +++ b/src/mortgage-calculator/dto/mortgage-calculator.dto.ts @@ -8,6 +8,7 @@ import { IsArray, IsOptional, ValidateNested, + IsString, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -108,8 +109,6 @@ export class MortgageScenarioDto { label?: string; } -import { IsString } from 'class-validator'; - export class CompareScenariosDto { @IsArray() @ValidateNested({ each: true }) diff --git a/src/open-house/dto/tour-request.dto.ts b/src/open-house/dto/tour-request.dto.ts index b07ac983..2679ee6d 100644 --- a/src/open-house/dto/tour-request.dto.ts +++ b/src/open-house/dto/tour-request.dto.ts @@ -9,7 +9,7 @@ import { Matches, IsBoolean, } from 'class-validator'; -import { TourType } from '../open-house.service'; +import { TourType } from '@prisma/client'; export class CreateTourRequestDto { @IsString() diff --git a/src/properties/properties.service.agent.spec.ts b/src/properties/properties.service.agent.spec.ts index d8faa397..2aef0054 100644 --- a/src/properties/properties.service.agent.spec.ts +++ b/src/properties/properties.service.agent.spec.ts @@ -70,7 +70,7 @@ describe('PropertiesService - Agent Assignment', () => { const result = await service.assignAgent( 'prop-1', { agentId: 'agent-1', commissionRate: 0.05, contactPhone: '12345' }, - { sub: 'owner-1', email: 'owner@test.com', role: 'USER', type: 'access' }, + { sub: 'owner-1', email: 'owner@test.com', role: 'USER', tier: 'FREE', type: 'access' }, ); expect((prisma as any).propertyAgent.create).toHaveBeenCalledWith({ @@ -93,7 +93,13 @@ describe('PropertiesService - Agent Assignment', () => { service.assignAgent( 'prop-1', { agentId: 'agent-1' }, - { sub: 'other-user', email: 'other@test.com', role: 'USER', type: 'access' }, + { + sub: 'other-user', + email: 'other@test.com', + role: 'USER', + tier: 'FREE', + type: 'access', + }, ), ).rejects.toBeInstanceOf(ForbiddenException); }); @@ -106,7 +112,7 @@ describe('PropertiesService - Agent Assignment', () => { service.assignAgent( 'prop-1', { agentId: 'user-1' }, - { sub: 'owner-1', email: 'owner@test.com', role: 'USER', type: 'access' }, + { sub: 'owner-1', email: 'owner@test.com', role: 'USER', tier: 'FREE', type: 'access' }, ), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -125,7 +131,7 @@ describe('PropertiesService - Agent Assignment', () => { 'prop-1', 'agent-1', { commissionRate: 0.04 }, - { sub: 'owner-1', email: 'owner@test.com', role: 'USER', type: 'access' }, + { sub: 'owner-1', email: 'owner@test.com', role: 'USER', tier: 'FREE', type: 'access' }, ); expect((prisma as any).propertyAgent.update).toHaveBeenCalledWith({ diff --git a/src/types/prisma.types.ts b/src/types/prisma.types.ts index 2cf9a0dc..0ea0c93e 100644 --- a/src/types/prisma.types.ts +++ b/src/types/prisma.types.ts @@ -71,6 +71,12 @@ export enum UserRole { AGENT = 'AGENT', } +export enum UserTier { + FREE = 'FREE', + PREMIUM = 'PREMIUM', + ENTERPRISE = 'ENTERPRISE', +} + export enum PropertyStatus { DRAFT = 'DRAFT', PENDING = 'PENDING',