diff --git a/.env.example b/.env.example index e81ad7a..6586f51 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ DB_NAME= MIGRATIONS_RUN=true SWAGGER_API_DOCS_PATH="/api-docs" +JWT_ACCESS_TOKEN_SECRET=your_access_token_secret +JWT_ACCESS_TOKEN_EXPIRY=1d +JWT_REFRESH_TOKEN_SECRET=your_refresh_token_secret +JWT_REFRESH_TOKEN_EXPIRY=1d +USE_MOCK=true + OCI_TENANCY_ID= OCI_USER_ID= diff --git a/package-lock.json b/package-lock.json index d855f10..d4a770c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,9 @@ "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", "@nestjs/event-emitter": "^2.1.1", + "@nestjs/jwt": "^10.2.0", "@nestjs/microservices": "^10.4.7", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.8", "@nestjs/swagger": "^8.0.5", "@nestjs/terminus": "^10.2.3", @@ -25,15 +27,21 @@ "@nestjs/typeorm": "^10.0.2", "amqp-connection-manager": "^4.1.14", "amqplib": "^0.10.4", + "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "google-libphonenumber": "^3.2.39", "handlebars": "^4.7.8", "ioredis": "^5.4.1", + "lodash": "^4.17.21", + "moment": "^2.30.1", "nestjs-i18n": "^10.4.9", "nestjs-pino": "^4.1.0", "nodemailer": "^6.9.16", "oci-common": "^2.99.0", "oci-sdk": "^2.99.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.13.1", "pino-http": "^10.3.0", "pino-pretty": "^13.0.0", @@ -46,11 +54,15 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", + "@types/google-libphonenumber": "^7.4.30", "@types/jest": "^29.5.2", + "@types/lodash": "^4.17.13", "@types/multer": "^1.4.12", "@types/node": "^20.3.1", "@types/nodemailer": "^6.4.16", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/parser": "^5.59.2", @@ -1877,6 +1889,71 @@ "node": ">=8" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/@mapbox/node-pre-gyp/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==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-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==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", @@ -2110,6 +2187,50 @@ "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@nestjs/jwt": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", + "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.5", + "jsonwebtoken": "9.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/jwt/node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@nestjs/jwt/node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "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/@nestjs/mapped-types": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.6.tgz", @@ -2188,6 +2309,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.4.8", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.8.tgz", @@ -2591,6 +2722,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -2681,6 +2822,13 @@ "@types/send": "*" } }, + "node_modules/@types/google-libphonenumber": { + "version": "7.4.30", + "resolved": "https://registry.npmjs.org/@types/google-libphonenumber/-/google-libphonenumber-7.4.30.tgz", + "integrity": "sha512-Td1X1ayRxePEm6/jPHUBs2tT6TzW1lrVB6ZX7ViPGellyzO/0xMNi+wx5nH6jEitjznq276VGIqjK5qAju0XVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -2764,6 +2912,13 @@ "integrity": "sha512-oBnY3csYnXfqZXDRBJwP1nDDJCW/+VMJ88UHT4DCy0deSXpJIQvMCwYlnmdW4M+u7PiSfQc44LmiFcUbJ8hLEw==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2833,6 +2988,38 @@ "@types/node": "*" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/pug": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", @@ -3414,6 +3601,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -3658,6 +3857,40 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -3931,6 +4164,20 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -4430,6 +4677,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -4800,6 +5056,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -4859,7 +5124,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -4894,6 +5158,12 @@ "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, "node_modules/constantinople": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", @@ -5199,6 +5469,12 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -5237,6 +5513,15 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6550,6 +6835,36 @@ "node": ">=12" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/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/fs-monkey": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", @@ -6561,7 +6876,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": { @@ -6587,6 +6901,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/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==", + "license": "ISC" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6749,6 +7090,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-libphonenumber": { + "version": "3.2.39", + "resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.39.tgz", + "integrity": "sha512-dpCbkY6ZxHXIHEFDwSir/gPBWkn22e2EixBv47guVs/NE8+qd35f1yu+fxQ8awRnHEXC60uhcPM9mbqmrD6nmw==", + "license": "(MIT AND Apache-2.0)", + "engines": { + "node": ">=0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -6876,6 +7226,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7026,6 +7382,19 @@ "node": ">=0.10" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -7130,7 +7499,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", @@ -9087,12 +9455,48 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "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.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "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", @@ -9107,6 +9511,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/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -9632,6 +10042,37 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/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/mjml": { "version": "4.15.3", "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.15.3.tgz", @@ -10102,6 +10543,15 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10243,6 +10693,12 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -10334,6 +10790,19 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -12339,6 +12808,42 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -12353,7 +12858,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" @@ -12413,6 +12917,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -13451,7 +13960,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -13467,7 +13975,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -13479,7 +13986,6 @@ "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", @@ -13500,7 +14006,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" @@ -13899,6 +14404,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -14426,6 +14937,50 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/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/terser": { "version": "5.36.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", @@ -15619,6 +16174,15 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", diff --git a/package.json b/package.json index 3d0bd50..4d7ea04 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", "@nestjs/event-emitter": "^2.1.1", + "@nestjs/jwt": "^10.2.0", "@nestjs/microservices": "^10.4.7", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.8", "@nestjs/swagger": "^8.0.5", "@nestjs/terminus": "^10.2.3", @@ -42,15 +44,21 @@ "@nestjs/typeorm": "^10.0.2", "amqp-connection-manager": "^4.1.14", "amqplib": "^0.10.4", + "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "google-libphonenumber": "^3.2.39", "handlebars": "^4.7.8", "ioredis": "^5.4.1", + "lodash": "^4.17.21", + "moment": "^2.30.1", "nestjs-i18n": "^10.4.9", "nestjs-pino": "^4.1.0", "nodemailer": "^6.9.16", "oci-common": "^2.99.0", "oci-sdk": "^2.99.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.13.1", "pino-http": "^10.3.0", "pino-pretty": "^13.0.0", @@ -63,11 +71,15 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", + "@types/google-libphonenumber": "^7.4.30", "@types/jest": "^29.5.2", + "@types/lodash": "^4.17.13", "@types/multer": "^1.4.12", "@types/node": "^20.3.1", "@types/nodemailer": "^6.4.16", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/parser": "^5.59.2", diff --git a/src/app.module.ts b/src/app.module.ts index a6ff8d1..6f215f2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,10 +4,13 @@ import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { I18nMiddleware, I18nModule } from 'nestjs-i18n'; import { LoggerModule } from 'nestjs-pino'; +import { AuthModule } from './auth/auth.module'; +import { OtpModule } from './common/modules/otp/otp.module'; import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters'; import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options'; import { buildI18nOptions } from './core/module-options/i18n-options'; import { buildValidationPipe } from './core/pipes'; +import { CustomerModule } from './customer/customer.module'; import { migrations } from './db'; import { DocumentModule } from './document/document.module'; import { HealthModule } from './health/health.module'; @@ -25,10 +28,12 @@ import { HealthModule } from './health/health.module'; inject: [ConfigService], }), I18nModule.forRoot(buildI18nOptions()), - HealthModule, - - // Application Modules + // App modules + AuthModule, + CustomerModule, DocumentModule, + HealthModule, + OtpModule, ], providers: [ // Global Pipes diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..be7076c --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthController } from './controllers'; +import { Device, User, UserNotificationSettings } from './entities'; +import { DeviceRepository, UserRepository } from './repositories'; +import { AuthService, DeviceService } from './services'; +import { UserService } from './services/user.service'; +import { AccessTokenStrategy } from './strategies'; + +@Module({ + imports: [TypeOrmModule.forFeature([User, UserNotificationSettings, Device]), JwtModule.register({})], + providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy], + controllers: [AuthController], + exports: [UserService], +}) +export class AuthModule {} diff --git a/src/auth/constants/country-code-regex.constant..ts b/src/auth/constants/country-code-regex.constant..ts new file mode 100644 index 0000000..f291168 --- /dev/null +++ b/src/auth/constants/country-code-regex.constant..ts @@ -0,0 +1 @@ +export const COUNTRY_CODE_REGEX = /^\+\d{1,3}$/; diff --git a/src/auth/constants/index.ts b/src/auth/constants/index.ts new file mode 100644 index 0000000..20d7b30 --- /dev/null +++ b/src/auth/constants/index.ts @@ -0,0 +1 @@ +export * from './country-code-regex.constant.'; diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts new file mode 100644 index 0000000..b36a907 --- /dev/null +++ b/src/auth/controllers/auth.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Headers, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { DEVICE_ID_HEADER } from '~/common/constants'; +import { AuthenticatedUser } from '~/common/decorators'; +import { AccessTokenGuard } from '~/common/guards'; +import { ResponseFactory } from '~/core/utils'; +import { + CreateUnverifiedUserRequestDto, + DisableBiometricRequestDto, + EnableBiometricRequestDto, + LoginRequestDto, + SetEmailRequestDto, + SetPasscodeRequestDto, + VerifyUserRequestDto, +} from '../dtos/request'; +import { SendRegisterOtpResponseDto } from '../dtos/response'; +import { LoginResponseDto } from '../dtos/response/login.response.dto'; +import { IJwtPayload } from '../interfaces'; +import { AuthService } from '../services'; + +@Controller('auth') +@ApiTags('Auth') +@ApiBearerAuth() +export class AuthController { + constructor(private readonly authService: AuthService) {} + @Post('register/otp') + async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserRequestDto) { + const phoneNumber = await this.authService.sendRegisterOtp(createUnverifiedUserDto); + return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber)); + } + + @Post('register/verify') + async verifyUser(@Body() verifyUserDto: VerifyUserRequestDto) { + const [res, user] = await this.authService.verifyUser(verifyUserDto); + return ResponseFactory.data(new LoginResponseDto(res, user)); + } + + @Post('register/set-email') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(AccessTokenGuard) + async setEmail(@AuthenticatedUser() { sub }: IJwtPayload, @Body() setEmailDto: SetEmailRequestDto) { + await this.authService.setEmail(sub, setEmailDto); + } + + @Post('register/set-passcode') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(AccessTokenGuard) + async setPasscode(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { passcode }: SetPasscodeRequestDto) { + await this.authService.setPasscode(sub, passcode); + } + + @Post('biometric/enable') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(AccessTokenGuard) + enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) { + return this.authService.enableBiometric(sub, enableBiometricDto); + } + + @Post('biometric/disable') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(AccessTokenGuard) + disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) { + return this.authService.disableBiometric(sub, disableBiometricDto); + } + + @Post('login') + async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) { + const [res, user] = await this.authService.login(loginDto, deviceId); + return ResponseFactory.data(new LoginResponseDto(res, user)); + } +} diff --git a/src/auth/controllers/index.ts b/src/auth/controllers/index.ts new file mode 100644 index 0000000..04d02fa --- /dev/null +++ b/src/auth/controllers/index.ts @@ -0,0 +1 @@ +export * from './auth.controller'; diff --git a/src/auth/dtos/request/create-unverified-user.request.dto.ts b/src/auth/dtos/request/create-unverified-user.request.dto.ts new file mode 100644 index 0000000..9302584 --- /dev/null +++ b/src/auth/dtos/request/create-unverified-user.request.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Matches } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; + +export class CreateUnverifiedUserRequestDto { + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode: string = '+966'; + + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; +} diff --git a/src/auth/dtos/request/disable-biometric.request.dto.ts b/src/auth/dtos/request/disable-biometric.request.dto.ts new file mode 100644 index 0000000..f30df1e --- /dev/null +++ b/src/auth/dtos/request/disable-biometric.request.dto.ts @@ -0,0 +1,4 @@ +import { PickType } from '@nestjs/swagger'; +import { EnableBiometricRequestDto } from './enable-biometric.request.dto'; + +export class DisableBiometricRequestDto extends PickType(EnableBiometricRequestDto, ['deviceId']) {} diff --git a/src/auth/dtos/request/enable-biometric.request.dto.ts b/src/auth/dtos/request/enable-biometric.request.dto.ts new file mode 100644 index 0000000..b582391 --- /dev/null +++ b/src/auth/dtos/request/enable-biometric.request.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class EnableBiometricRequestDto { + @ApiProperty({ example: 'device-id' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.deviceId' }) }) + deviceId!: string; + + @ApiProperty({ example: 'publicKey' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.publicKey' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.publicKey' }) }) + publicKey!: string; +} diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts new file mode 100644 index 0000000..2016270 --- /dev/null +++ b/src/auth/dtos/request/index.ts @@ -0,0 +1,7 @@ +export * from './create-unverified-user.request.dto'; +export * from './disable-biometric.request.dto'; +export * from './enable-biometric.request.dto'; +export * from './login.request.dto'; +export * from './set-email.request.dto'; +export * from './set-passcode.request.dto'; +export * from './verify-user.request.dto'; diff --git a/src/auth/dtos/request/login.request.dto.ts b/src/auth/dtos/request/login.request.dto.ts new file mode 100644 index 0000000..9599396 --- /dev/null +++ b/src/auth/dtos/request/login.request.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsEnum, IsString, ValidateIf } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { GrantType } from '~/auth/enums'; +export class LoginRequestDto { + @ApiProperty({ example: GrantType.PASSWORD }) + @IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) }) + grantType!: GrantType; + + @ApiProperty({ example: 'test@test.com' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + email!: string; + + @ApiProperty({ example: '123456' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) + @ValidateIf((o) => o.grantType === GrantType.PASSWORD) + password!: string; + + @ApiProperty({ example: 'device-token' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceToken' }) }) + @ValidateIf((o) => o.grantType === GrantType.BIOMETRIC) + deviceToken!: string; +} diff --git a/src/auth/dtos/request/set-email.request.dto.ts b/src/auth/dtos/request/set-email.request.dto.ts new file mode 100644 index 0000000..489c08f --- /dev/null +++ b/src/auth/dtos/request/set-email.request.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class SetEmailRequestDto { + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + email!: string; +} diff --git a/src/auth/dtos/request/set-passcode.request.dto.ts b/src/auth/dtos/request/set-passcode.request.dto.ts new file mode 100644 index 0000000..aca81d3 --- /dev/null +++ b/src/auth/dtos/request/set-passcode.request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumberString, MaxLength, MinLength } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +const PASSCODE_LENGTH = 6; + +export class SetPasscodeRequestDto { + @ApiProperty({ example: '123456' }) + @IsNumberString( + { no_symbols: true }, + { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.passcode' }) }, + ) + @MinLength(PASSCODE_LENGTH, { message: i18n('validation.MinLength', { path: 'general', property: 'auth.passcode' }) }) + @MaxLength(PASSCODE_LENGTH, { message: i18n('validation.MaxLength', { path: 'general', property: 'auth.passcode' }) }) + passcode!: string; +} diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts new file mode 100644 index 0000000..c90d495 --- /dev/null +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumberString, MaxLength, MinLength } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto'; + +export class VerifyUserRequestDto extends CreateUnverifiedUserRequestDto { + @ApiProperty({ example: '111111' }) + @IsNumberString( + { no_symbols: true }, + { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, + ) + @MaxLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + @MinLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + otp!: string; +} diff --git a/src/auth/dtos/response/index.ts b/src/auth/dtos/response/index.ts new file mode 100644 index 0000000..bd26a22 --- /dev/null +++ b/src/auth/dtos/response/index.ts @@ -0,0 +1,3 @@ +export * from './send-register-otp.response.dto'; +export * from './user.response.dto'; +export * from './verify-user.response.dto'; diff --git a/src/auth/dtos/response/login.response.dto.ts b/src/auth/dtos/response/login.response.dto.ts new file mode 100644 index 0000000..75da7b1 --- /dev/null +++ b/src/auth/dtos/response/login.response.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '~/auth/entities'; +import { ILoginResponse } from '~/auth/interfaces'; +import { CustomerResponseDto } from '~/customer/dtos/response'; +import { UserResponseDto } from './user.response.dto'; + +export class LoginResponseDto { + @ApiProperty() + accessToken!: string; + + @ApiProperty() + refreshToken!: string; + + @ApiProperty() + expiresAt!: Date; + + @ApiProperty({ example: UserResponseDto }) + user!: UserResponseDto; + + @ApiProperty({ example: CustomerResponseDto }) + customer!: CustomerResponseDto; + + constructor(IVerifyUserResponse: ILoginResponse, user: User) { + this.user = new UserResponseDto(user); + this.customer = new CustomerResponseDto(user.customer); + this.accessToken = IVerifyUserResponse.accessToken; + this.refreshToken = IVerifyUserResponse.refreshToken; + this.expiresAt = IVerifyUserResponse.expiresAt; + } +} diff --git a/src/auth/dtos/response/send-register-otp.response.dto.ts b/src/auth/dtos/response/send-register-otp.response.dto.ts new file mode 100644 index 0000000..ec6f35a --- /dev/null +++ b/src/auth/dtos/response/send-register-otp.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SendRegisterOtpResponseDto { + @ApiProperty() + phoneNumber!: string; + + constructor(phoneNumber: string) { + this.phoneNumber = phoneNumber; + } +} diff --git a/src/auth/dtos/response/user.response.dto.ts b/src/auth/dtos/response/user.response.dto.ts new file mode 100644 index 0000000..dd36099 --- /dev/null +++ b/src/auth/dtos/response/user.response.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '~/auth/entities'; +import { Roles } from '~/auth/enums'; + +export class UserResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty() + email!: string; + + @ApiProperty() + phoneNumber!: string; + + @ApiProperty() + countryCode!: string; + + @ApiProperty() + isPasswordSet!: boolean; + + @ApiProperty() + isProfileCompleted!: boolean; + + @ApiProperty() + roles!: Roles[]; + + constructor(user: User) { + this.id = user.id; + this.email = user.email; + this.phoneNumber = user.phoneNumber; + this.countryCode = user.countryCode; + this.isPasswordSet = user.isPasswordSet; + this.isProfileCompleted = user.isProfileCompleted; + this.roles = user.roles; + } +} diff --git a/src/auth/dtos/response/verify-user.response.dto.ts b/src/auth/dtos/response/verify-user.response.dto.ts new file mode 100644 index 0000000..21f426b --- /dev/null +++ b/src/auth/dtos/response/verify-user.response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '~/auth/entities'; +import { ILoginResponse } from '~/auth/interfaces'; + +export class VerifyUserResponseDto { + @ApiProperty() + accessToken!: string; + + @ApiProperty() + refreshToken!: string; + + @ApiProperty() + expiresAt!: Date; + + @ApiProperty() + user!: User; + + constructor(data: ILoginResponse, user: User) { + this.accessToken = data.accessToken; + this.refreshToken = data.refreshToken; + this.expiresAt = data.expiresAt; + this.user = user; + } +} diff --git a/src/auth/entities/device.entity.ts b/src/auth/entities/device.entity.ts new file mode 100644 index 0000000..3042b70 --- /dev/null +++ b/src/auth/entities/device.entity.ts @@ -0,0 +1,30 @@ +import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { User } from './user.entity'; + +@Entity('devices') +export class Device { + @PrimaryColumn('varchar', { length: 255 }) + deviceId!: string; + + @Column('varchar', { name: 'user_id' }) + userId!: string; + + @Column('varchar', { name: 'device_name', nullable: true }) + deviceName?: string | null; + + @Column('varchar', { name: 'public_key', nullable: true }) + publicKey?: string | null; + + @Column('timestamp with time zone', { name: 'last_access_on', default: () => 'CURRENT_TIMESTAMP' }) + lastAccessOn!: Date; + + @ManyToOne(() => User, (user) => user.devices, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/src/auth/entities/index.ts b/src/auth/entities/index.ts new file mode 100644 index 0000000..423ba61 --- /dev/null +++ b/src/auth/entities/index.ts @@ -0,0 +1,3 @@ +export * from './device.entity'; +export * from './user-notification-settings.entity'; +export * from './user.entity'; diff --git a/src/auth/entities/user-notification-settings.entity.ts b/src/auth/entities/user-notification-settings.entity.ts new file mode 100644 index 0000000..c740d30 --- /dev/null +++ b/src/auth/entities/user-notification-settings.entity.ts @@ -0,0 +1,36 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('user_notification_settings') +export class UserNotificationSettings extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'is_email_enabled', default: false }) + isEmailEnabled!: boolean; + + @Column({ name: 'is_push_enabled', default: false }) + isPushEnabled!: boolean; + + @Column({ name: 'is_sms_enabled', default: false }) + isSmsEnabled!: boolean; + + @OneToOne(() => User, (user) => user.notificationSettings, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' }) + updatedAt!: Date; +} diff --git a/src/auth/entities/user.entity.ts b/src/auth/entities/user.entity.ts new file mode 100644 index 0000000..a85d966 --- /dev/null +++ b/src/auth/entities/user.entity.ts @@ -0,0 +1,82 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Otp } from '~/common/modules/otp/entities'; +import { Customer } from '~/customer/entities/customer.entity'; +import { Document } from '~/document/entities'; +import { Roles } from '../enums'; +import { Device } from './device.entity'; +import { UserNotificationSettings } from './user-notification-settings.entity'; + +@Entity('users') +export class User extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'email' }) + email!: string; + + @Column('varchar', { length: 255, name: 'phone_number' }) + phoneNumber!: string; + + @Column('varchar', { length: 10, name: 'country_code' }) + countryCode!: string; + + @Column('varchar', { length: 255, name: 'password', nullable: true }) + password!: string; + + @Column('varchar', { length: 255, name: 'salt', nullable: true }) + salt!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'google_id' }) + googleId!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'apple_id' }) + appleId!: string; + + @Column('boolean', { default: false, name: 'is_profile_completed' }) + isProfileCompleted!: boolean; + + @Column('text', { nullable: true, array: true, name: 'roles' }) + roles!: Roles[]; + + @Column('varchar', { name: 'profile_picture_id', nullable: true }) + profilePictureId!: string; + + @OneToOne(() => Document, (document) => document.user, { cascade: true, nullable: true }) + @JoinColumn({ name: 'profile_picture_id' }) + profilePicture!: Document; + + @OneToMany(() => Otp, (otp) => otp.user) + otp!: Otp[]; + + @OneToOne(() => UserNotificationSettings, (notificationSettings) => notificationSettings.user, { + cascade: true, + eager: true, + }) + notificationSettings!: UserNotificationSettings; + + @OneToOne(() => Customer, (customer) => customer.user, { cascade: true, eager: true }) + customer!: Customer; + + @OneToMany(() => Device, (device) => device.user) + devices!: Device[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' }) + updatedAt!: Date; + + get isPasswordSet(): boolean { + return !!this.password; + } +} diff --git a/src/auth/enums/grant-type.enum.ts b/src/auth/enums/grant-type.enum.ts new file mode 100644 index 0000000..4a16d92 --- /dev/null +++ b/src/auth/enums/grant-type.enum.ts @@ -0,0 +1,4 @@ +export enum GrantType { + PASSWORD = 'PASSWORD', + BIOMETRIC = 'BIOMETRIC', +} diff --git a/src/auth/enums/index.ts b/src/auth/enums/index.ts new file mode 100644 index 0000000..c61885e --- /dev/null +++ b/src/auth/enums/index.ts @@ -0,0 +1,2 @@ +export * from './grant-type.enum'; +export * from './roles.enum'; diff --git a/src/auth/enums/roles.enum.ts b/src/auth/enums/roles.enum.ts new file mode 100644 index 0000000..7df8306 --- /dev/null +++ b/src/auth/enums/roles.enum.ts @@ -0,0 +1,4 @@ +export enum Roles { + JUNIOR = 'JUNIOR', + GUARDIAN = 'GUARDIAN', +} diff --git a/src/auth/interfaces/index.ts b/src/auth/interfaces/index.ts new file mode 100644 index 0000000..a27228e --- /dev/null +++ b/src/auth/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './jwt-payload.interface'; +export * from './login-response.interface'; diff --git a/src/auth/interfaces/jwt-payload.interface.ts b/src/auth/interfaces/jwt-payload.interface.ts new file mode 100644 index 0000000..59429f7 --- /dev/null +++ b/src/auth/interfaces/jwt-payload.interface.ts @@ -0,0 +1,4 @@ +export interface IJwtPayload { + sub: string; + roles: string[]; +} diff --git a/src/auth/interfaces/login-response.interface.ts b/src/auth/interfaces/login-response.interface.ts new file mode 100644 index 0000000..46903f5 --- /dev/null +++ b/src/auth/interfaces/login-response.interface.ts @@ -0,0 +1,5 @@ +export interface ILoginResponse { + accessToken: string; + refreshToken: string; + expiresAt: Date; +} diff --git a/src/auth/repositories/device.repository.ts b/src/auth/repositories/device.repository.ts new file mode 100644 index 0000000..d9cc374 --- /dev/null +++ b/src/auth/repositories/device.repository.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Device } from '../entities'; + +@Injectable() +export class DeviceRepository { + constructor(@InjectRepository(Device) private readonly deviceRepository: Repository) {} + + findUserDeviceById(deviceId: string, userId: string) { + return this.deviceRepository.findOne({ where: { deviceId, userId } }); + } + + createDevice(data: Partial) { + return this.deviceRepository.save(data); + } + + updateDevice(deviceId: string, data: Partial) { + return this.deviceRepository.update({ deviceId }, data); + } +} diff --git a/src/auth/repositories/index.ts b/src/auth/repositories/index.ts new file mode 100644 index 0000000..a12ec2d --- /dev/null +++ b/src/auth/repositories/index.ts @@ -0,0 +1,2 @@ +export * from './device.repository'; +export * from './user.repository'; diff --git a/src/auth/repositories/user.repository.ts b/src/auth/repositories/user.repository.ts new file mode 100644 index 0000000..dedda16 --- /dev/null +++ b/src/auth/repositories/user.repository.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsWhere, Repository } from 'typeorm'; +import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request'; +import { Customer } from '~/customer/entities'; +import { User, UserNotificationSettings } from '../entities'; + +@Injectable() +export class UserRepository { + constructor(@InjectRepository(User) private readonly userRepository: Repository) {} + + createUnverifiedUser(data: Partial) { + return this.userRepository.save( + this.userRepository.create({ + phoneNumber: data.phoneNumber, + countryCode: data.countryCode, + roles: data.roles, + notificationSettings: UserNotificationSettings.create(), + }), + ); + } + + findOne(where: FindOptionsWhere) { + return this.userRepository.findOne({ where }); + } + + updateNotificationSettings(user: User, body: UpdateNotificationsSettingsRequestDto) { + user.notificationSettings = UserNotificationSettings.create({ ...user.notificationSettings, ...body }); + return this.userRepository.save(user); + } + + verifyUserAndCreateCustomer(user: User) { + user.customer = Customer.create({ ...user.customer, id: user.id, isGuardian: true }); + + return this.userRepository.save(user); + } + + update(userId: string, data: Partial) { + return this.userRepository.update(userId, data); + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts new file mode 100644 index 0000000..18d13f1 --- /dev/null +++ b/src/auth/services/auth.service.ts @@ -0,0 +1,202 @@ +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { OtpScope, OtpType } from '~/common/modules/otp/enums'; +import { OtpService } from '~/common/modules/otp/services'; +import { + CreateUnverifiedUserRequestDto, + DisableBiometricRequestDto, + EnableBiometricRequestDto, + LoginRequestDto, + SetEmailRequestDto, +} from '../dtos/request'; +import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto'; +import { User } from '../entities'; +import { GrantType } from '../enums'; +import { ILoginResponse } from '../interfaces'; +import { removePadding, verifySignature } from '../utils'; +import { DeviceService } from './device.service'; +import { UserService } from './user.service'; + +const ONE_THOUSAND = 1000; +const SALT_ROUNDS = 10; +@Injectable() +export class AuthService { + constructor( + private readonly otpService: OtpService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + private readonly userService: UserService, + private readonly deviceService: DeviceService, + ) {} + async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { + const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode }); + + return this.otpService.generateAndSendOtp({ + userId: user.id, + phoneNumber: user.phoneNumber, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, + }); + } + + async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { + const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber }); + + if (user.isPasswordSet) { + throw new BadRequestException('USERS.PHONE_ALREADY_VERIFIED'); + } + + const isOtpValid = await this.otpService.verifyOtp({ + userId: user.id, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, + value: verifyUserDto.otp, + }); + + if (!isOtpValid) { + throw new BadRequestException('USERS.INVALID_OTP'); + } + + const updatedUser = await this.userService.verifyUserAndCreateCustomer(user); + + const tokens = await this.generateAuthToken(updatedUser); + + return [tokens, updatedUser]; + } + + async setEmail(userId: string, { email }: SetEmailRequestDto) { + const user = await this.userService.findUserOrThrow({ id: userId }); + + if (user.email) { + throw new BadRequestException('USERS.EMAIL_ALREADY_SET'); + } + + return this.userService.setEmail(userId, email); + } + + async setPasscode(userId: string, passcode: string) { + const user = await this.userService.findUserOrThrow({ id: userId }); + + if (user.password) { + throw new BadRequestException('USERS.PASSCODE_ALREADY_SET'); + } + const salt = bcrypt.genSaltSync(SALT_ROUNDS); + const hashedPasscode = bcrypt.hashSync(passcode, salt); + + await this.userService.setPasscode(userId, hashedPasscode, salt); + } + + async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) { + const device = await this.deviceService.findUserDeviceById(deviceId, userId); + + if (!device) { + return this.deviceService.createDevice({ + deviceId, + userId, + publicKey, + }); + } + + if (device.publicKey) { + throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_ENABLED'); + } + + return this.deviceService.updateDevice(deviceId, { publicKey }); + } + + async disableBiometric(userId: string, { deviceId }: DisableBiometricRequestDto) { + const device = await this.deviceService.findUserDeviceById(deviceId, userId); + + if (!device) { + throw new BadRequestException('AUTH.DEVICE_NOT_FOUND'); + } + + if (!device.publicKey) { + throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED'); + } + + return this.deviceService.updateDevice(deviceId, { publicKey: null }); + } + + async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { + const user = await this.userService.findUser({ email: loginDto.email }); + let tokens; + + if (!user) { + throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); + } + + if (loginDto.grantType === GrantType.PASSWORD) { + tokens = await this.loginWithPassword(loginDto, user); + } else { + tokens = await this.loginWithBiometric(loginDto, user, deviceId); + } + + this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date() }); + + return [tokens, user]; + } + + private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise { + const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); + + if (!isPasswordValid) { + throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); + } + + const tokens = await this.generateAuthToken(user); + + return tokens; + } + + private async loginWithBiometric(loginDto: LoginRequestDto, user: User, deviceId: string): Promise { + const device = await this.deviceService.findUserDeviceById(deviceId, user.id); + + if (!device) { + throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); + } + + if (!device.publicKey) { + throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); + } + + const cleanToken = removePadding(loginDto.deviceToken); + const isValidToken = await verifySignature( + device.publicKey, + cleanToken, + `${user.email} - ${device.deviceId}`, + 'SHA1', + ); + + if (!isValidToken) { + throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); + } + + const tokens = await this.generateAuthToken(user); + + return tokens; + } + + private async generateAuthToken(user: User) { + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.sign( + { sub: user.id, roles: user.roles }, + { + expiresIn: this.configService.getOrThrow('JWT_ACCESS_TOKEN_EXPIRY'), + secret: this.configService.getOrThrow('JWT_ACCESS_TOKEN_SECRET'), + }, + ), + this.jwtService.sign( + { sub: user.id, roles: user.roles }, + { + expiresIn: this.configService.getOrThrow('JWT_REFRESH_TOKEN_EXPIRY'), + secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'), + }, + ), + ]); + + return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) }; + } +} diff --git a/src/auth/services/device.service.ts b/src/auth/services/device.service.ts new file mode 100644 index 0000000..a6b4266 --- /dev/null +++ b/src/auth/services/device.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { Device } from '../entities'; +import { DeviceRepository } from '../repositories'; + +@Injectable() +export class DeviceService { + constructor(private readonly deviceRepository: DeviceRepository) {} + findUserDeviceById(deviceId: string, userId: string) { + return this.deviceRepository.findUserDeviceById(deviceId, userId); + } + + createDevice(data: Partial) { + return this.deviceRepository.createDevice(data); + } + + updateDevice(deviceId: string, data: Partial) { + return this.deviceRepository.updateDevice(deviceId, data); + } +} diff --git a/src/auth/services/index.ts b/src/auth/services/index.ts new file mode 100644 index 0000000..96bcd76 --- /dev/null +++ b/src/auth/services/index.ts @@ -0,0 +1,3 @@ +export * from './auth.service'; +export * from './device.service'; +export * from './user.service'; diff --git a/src/auth/services/user.service.ts b/src/auth/services/user.service.ts new file mode 100644 index 0000000..c41fc03 --- /dev/null +++ b/src/auth/services/user.service.ts @@ -0,0 +1,64 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { FindOptionsWhere } from 'typeorm'; +import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request'; +import { CreateUnverifiedUserRequestDto } from '../dtos/request'; +import { User } from '../entities'; +import { Roles } from '../enums'; +import { UserRepository } from '../repositories'; + +@Injectable() +export class UserService { + constructor(private readonly userRepository: UserRepository) {} + + async updateNotificationSettings(userId: string, body: UpdateNotificationsSettingsRequestDto) { + const user = await this.findUserOrThrow({ id: userId }); + + const notificationSettings = (await this.userRepository.updateNotificationSettings(user, body)) + .notificationSettings; + + return notificationSettings; + } + + findUser(where: FindOptionsWhere) { + return this.userRepository.findOne(where); + } + + async findUserOrThrow(where: FindOptionsWhere) { + const user = await this.findUser(where); + + if (!user) { + throw new BadRequestException('USERS.NOT_FOUND'); + } + + return user; + } + + async findOrCreateUser({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { + const user = await this.userRepository.findOne({ phoneNumber }); + + if (!user) { + return this.userRepository.createUnverifiedUser({ phoneNumber, countryCode, roles: [Roles.GUARDIAN] }); + } + if (user && user.roles.includes(Roles.GUARDIAN) && user.isPasswordSet) { + throw new BadRequestException('USERS.PHONE_NUMBER_ALREADY_EXISTS'); + } + + if (user && user.roles.includes(Roles.JUNIOR)) { + //TODO add role Guardian to the existing user and send OTP + } + + return user; + } + + setEmail(userId: string, email: string) { + return this.userRepository.update(userId, { email }); + } + + setPasscode(userId: string, passcode: string, salt: string) { + return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true }); + } + + verifyUserAndCreateCustomer(user: User) { + return this.userRepository.verifyUserAndCreateCustomer(user); + } +} diff --git a/src/auth/strategies/access-token.strategy.ts b/src/auth/strategies/access-token.strategy.ts new file mode 100644 index 0000000..7dba5a0 --- /dev/null +++ b/src/auth/strategies/access-token.strategy.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { IJwtPayload } from '../interfaces'; + +@Injectable() +export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_ACCESS_TOKEN_SECRET'), + }); + } + + validate(payload: IJwtPayload) { + return payload; + } +} diff --git a/src/auth/strategies/index.ts b/src/auth/strategies/index.ts new file mode 100644 index 0000000..f06f2b5 --- /dev/null +++ b/src/auth/strategies/index.ts @@ -0,0 +1 @@ +export * from './access-token.strategy'; diff --git a/src/auth/utils/crypt.ts b/src/auth/utils/crypt.ts new file mode 100644 index 0000000..3127220 --- /dev/null +++ b/src/auth/utils/crypt.ts @@ -0,0 +1,26 @@ +import * as crypto from 'crypto'; + +export function verifySignature( + publicKeyBase64: string, + signatureBase64: string, + message: string, + algorithm: 'SHA1' | 'SHA384', +) { + const signatureBuffer = Buffer.from(signatureBase64, 'base64'); + + const publicKeyPEM = '-----BEGIN PUBLIC KEY-----\n' + publicKeyBase64 + '\n-----END PUBLIC KEY-----'; + const verifier = crypto.createVerify(algorithm); + verifier.update(message, 'utf8'); + return verifier.verify( + { + key: publicKeyPEM, + padding: crypto.constants.RSA_PKCS1_PADDING, + }, + signatureBuffer, + ); +} + +export function removePadding(originalSignature: string) { + const buffer = Buffer.from(originalSignature, 'base64'); + return buffer.toString('base64'); +} diff --git a/src/auth/utils/index.ts b/src/auth/utils/index.ts new file mode 100644 index 0000000..e2511a3 --- /dev/null +++ b/src/auth/utils/index.ts @@ -0,0 +1 @@ +export * from './crypt'; diff --git a/src/common/constants/global.constant.ts b/src/common/constants/global.constant.ts new file mode 100644 index 0000000..4c4f677 --- /dev/null +++ b/src/common/constants/global.constant.ts @@ -0,0 +1 @@ +export const DEVICE_ID_HEADER = 'x-client-id'; diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts new file mode 100644 index 0000000..c1f9866 --- /dev/null +++ b/src/common/constants/index.ts @@ -0,0 +1 @@ +export * from './global.constant'; diff --git a/src/common/decorators/index.ts b/src/common/decorators/index.ts new file mode 100644 index 0000000..c5aae70 --- /dev/null +++ b/src/common/decorators/index.ts @@ -0,0 +1,2 @@ +export * from './public.decorator'; +export * from './user.decorator'; diff --git a/src/common/decorators/public.decorator.ts b/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/common/decorators/user.decorator.ts b/src/common/decorators/user.decorator.ts new file mode 100644 index 0000000..4e14d63 --- /dev/null +++ b/src/common/decorators/user.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const AuthenticatedUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest(); + return req.user; +}); diff --git a/src/common/guards/access-token.guard.ts b/src/common/guards/access-token.guard.ts new file mode 100644 index 0000000..8d03365 --- /dev/null +++ b/src/common/guards/access-token.guard.ts @@ -0,0 +1,23 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Observable } from 'rxjs'; +import { IS_PUBLIC_KEY } from '../decorators'; + +@Injectable() +export class AccessTokenGuard extends AuthGuard('access-token') { + constructor(private reflector: Reflector) { + super(); + } + canActivate(context: ExecutionContext): boolean | Promise | Observable { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + return super.canActivate(context); + } +} diff --git a/src/common/guards/index.ts b/src/common/guards/index.ts new file mode 100644 index 0000000..874d51c --- /dev/null +++ b/src/common/guards/index.ts @@ -0,0 +1 @@ +export * from './access-token.guard'; diff --git a/src/common/modules/otp/constants/index.ts b/src/common/modules/otp/constants/index.ts new file mode 100644 index 0000000..664c667 --- /dev/null +++ b/src/common/modules/otp/constants/index.ts @@ -0,0 +1 @@ +export * from './otp-default.constant'; diff --git a/src/common/modules/otp/constants/otp-default.constant.ts b/src/common/modules/otp/constants/otp-default.constant.ts new file mode 100644 index 0000000..b411813 --- /dev/null +++ b/src/common/modules/otp/constants/otp-default.constant.ts @@ -0,0 +1,2 @@ +export const DEFAULT_OTP_LENGTH = 6; +export const DEFAULT_OTP_DIGIT = '1'; diff --git a/src/common/modules/otp/dtos/request/generate-otp-request.request.dto.ts b/src/common/modules/otp/dtos/request/generate-otp-request.request.dto.ts new file mode 100644 index 0000000..8c61ca1 --- /dev/null +++ b/src/common/modules/otp/dtos/request/generate-otp-request.request.dto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty } from 'class-validator'; +import { UserLocale } from '~/core/enums'; +import { OtpScope, OtpType } from '../../enums'; + +export class GenerateOtpRequestDto { + @IsNotEmpty() + userId!: string; + + @IsNotEmpty() + phoneNumber!: string; + + @IsNotEmpty() + scope!: OtpScope; + + @IsNotEmpty() + language?: UserLocale = UserLocale.ENGLISH; + + @IsNotEmpty() + otpType!: OtpType; +} diff --git a/src/common/modules/otp/dtos/request/index.ts b/src/common/modules/otp/dtos/request/index.ts new file mode 100644 index 0000000..40e2e02 --- /dev/null +++ b/src/common/modules/otp/dtos/request/index.ts @@ -0,0 +1,2 @@ +export * from './generate-otp-request.request.dto'; +export * from './verify-otp-request.dto'; diff --git a/src/common/modules/otp/dtos/request/verify-otp-request.dto.ts b/src/common/modules/otp/dtos/request/verify-otp-request.dto.ts new file mode 100644 index 0000000..ac295cf --- /dev/null +++ b/src/common/modules/otp/dtos/request/verify-otp-request.dto.ts @@ -0,0 +1,16 @@ +import { IsNotEmpty } from 'class-validator'; +import { OtpScope, OtpType } from '../../enums'; + +export class VerifyOtpRequestDto { + @IsNotEmpty() + userId!: string; + + @IsNotEmpty() + scope!: OtpScope; + + @IsNotEmpty() + otpType!: OtpType; + + @IsNotEmpty() + value!: string; +} diff --git a/src/common/modules/otp/entities/index.ts b/src/common/modules/otp/entities/index.ts new file mode 100644 index 0000000..fd3fd2a --- /dev/null +++ b/src/common/modules/otp/entities/index.ts @@ -0,0 +1 @@ +export * from './otp.entity'; diff --git a/src/common/modules/otp/entities/otp.entity.ts b/src/common/modules/otp/entities/otp.entity.ts new file mode 100644 index 0000000..4648457 --- /dev/null +++ b/src/common/modules/otp/entities/otp.entity.ts @@ -0,0 +1,33 @@ +import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from '~/auth/entities'; +import { OtpScope, OtpType } from '../enums'; + +@Entity('otp') +export class Otp { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('varchar', { length: 255, name: 'value' }) + value!: string; + + @Index() + @Column('varchar', { length: 255, name: 'scope' }) + scope!: OtpScope; + + @Column('varchar', { length: 255, name: 'otp_type' }) + otpType!: OtpType; + + @Column('timestamp with time zone', { name: 'expires_at' }) + expiresAt!: Date; + + @Index() + @Column('varchar', { name: 'user_id' }) + userId!: string; + + @ManyToOne(() => User, (user) => user.otp, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt!: Date; +} diff --git a/src/common/modules/otp/enums/index.ts b/src/common/modules/otp/enums/index.ts new file mode 100644 index 0000000..24dca48 --- /dev/null +++ b/src/common/modules/otp/enums/index.ts @@ -0,0 +1,2 @@ +export * from './otp-scope.enum'; +export * from './otp-type.enum'; diff --git a/src/common/modules/otp/enums/otp-scope.enum.ts b/src/common/modules/otp/enums/otp-scope.enum.ts new file mode 100644 index 0000000..9cb827b --- /dev/null +++ b/src/common/modules/otp/enums/otp-scope.enum.ts @@ -0,0 +1,3 @@ +export enum OtpScope { + VERIFY_PHONE = 'VERIFY_PHONE', +} diff --git a/src/common/modules/otp/enums/otp-type.enum.ts b/src/common/modules/otp/enums/otp-type.enum.ts new file mode 100644 index 0000000..774c6f3 --- /dev/null +++ b/src/common/modules/otp/enums/otp-type.enum.ts @@ -0,0 +1,4 @@ +export enum OtpType { + SMS = 'SMS', + EMAIL = 'EMAIL', +} diff --git a/src/common/modules/otp/otp.module.ts b/src/common/modules/otp/otp.module.ts new file mode 100644 index 0000000..6a8097c --- /dev/null +++ b/src/common/modules/otp/otp.module.ts @@ -0,0 +1,13 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Otp } from './entities'; +import { OtpRepository } from './repositories'; +import { OtpService } from './services/otp.service'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([Otp])], + providers: [OtpService, OtpRepository], + exports: [OtpService], +}) +export class OtpModule {} diff --git a/src/common/modules/otp/repositories/index.ts b/src/common/modules/otp/repositories/index.ts new file mode 100644 index 0000000..0343a98 --- /dev/null +++ b/src/common/modules/otp/repositories/index.ts @@ -0,0 +1 @@ +export * from './otp.repository'; diff --git a/src/common/modules/otp/repositories/otp.repository.ts b/src/common/modules/otp/repositories/otp.repository.ts new file mode 100644 index 0000000..57dd7b8 --- /dev/null +++ b/src/common/modules/otp/repositories/otp.repository.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MoreThan, Repository } from 'typeorm'; +import { VerifyOtpRequestDto } from '../dtos/request'; +import { Otp } from '../entities'; +const FIVE = 5; +const SIXTY = 60; +const ONE_THOUSAND = 1000; +const FIVE_MINUTES_IN_MILLISECONDS = FIVE * SIXTY * ONE_THOUSAND; +@Injectable() +export class OtpRepository { + constructor(@InjectRepository(Otp) private readonly otpRepository: Repository) {} + + createOtp(otp: Partial) { + return this.otpRepository.save( + this.otpRepository.create({ + userId: otp.userId, + value: otp.value, + scope: otp.scope, + otpType: otp.otpType, + expiresAt: new Date(Date.now() + FIVE_MINUTES_IN_MILLISECONDS), + }), + ); + } + + findOtp(otp: VerifyOtpRequestDto) { + return this.otpRepository.findOne({ + where: { + userId: otp.userId, + scope: otp.scope, + value: otp.value, + otpType: otp.otpType, + expiresAt: MoreThan(new Date()), + }, + order: { + createdAt: 'DESC', + }, + }); + } +} diff --git a/src/common/modules/otp/services/index.ts b/src/common/modules/otp/services/index.ts new file mode 100644 index 0000000..88ec635 --- /dev/null +++ b/src/common/modules/otp/services/index.ts @@ -0,0 +1 @@ +export * from './otp.service'; diff --git a/src/common/modules/otp/services/otp.service.ts b/src/common/modules/otp/services/otp.service.ts new file mode 100644 index 0000000..5bd42fb --- /dev/null +++ b/src/common/modules/otp/services/otp.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DEFAULT_OTP_DIGIT, DEFAULT_OTP_LENGTH } from '../constants'; +import { GenerateOtpRequestDto, VerifyOtpRequestDto } from '../dtos/request'; +import { OtpRepository } from '../repositories'; +import { generateRandomOtp } from '../utils'; + +@Injectable() +export class OtpService { + constructor(private readonly configService: ConfigService, private readonly otpRepository: OtpRepository) {} + private useMock = this.configService.get('USE_MOCK', false); + async generateAndSendOtp(sendotpRequest: GenerateOtpRequestDto) { + const otp = this.useMock ? DEFAULT_OTP_DIGIT.repeat(DEFAULT_OTP_LENGTH) : generateRandomOtp(DEFAULT_OTP_LENGTH); + + await this.otpRepository.createOtp({ ...sendotpRequest, value: otp }); + + this.sendOtp(sendotpRequest, otp); + + return sendotpRequest.phoneNumber.replace(/.(?=.{4})/g, '*'); + } + + async verifyOtp(verifyOtpRequest: VerifyOtpRequestDto) { + const otp = await this.otpRepository.findOtp(verifyOtpRequest); + + return !!otp; + } + + private sendOtp(sendotpRequest: GenerateOtpRequestDto, otp: string) { + // TODO: send OTP to the user + return; + } +} diff --git a/src/common/modules/otp/utils/index.ts b/src/common/modules/otp/utils/index.ts new file mode 100644 index 0000000..c6ca86a --- /dev/null +++ b/src/common/modules/otp/utils/index.ts @@ -0,0 +1 @@ +export * from './otp-generator.util'; diff --git a/src/common/modules/otp/utils/otp-generator.util.ts b/src/common/modules/otp/utils/otp-generator.util.ts new file mode 100644 index 0000000..16aac61 --- /dev/null +++ b/src/common/modules/otp/utils/otp-generator.util.ts @@ -0,0 +1,9 @@ +import { getRandomValues } from 'crypto'; +import { shuffle } from 'lodash'; +const ZERO = 0; +const ONE = 1; +export function generateRandomOtp(length: number): string { + const u32 = getRandomValues(new Uint32Array(ONE))[ZERO]; + const randomOtpDigits = u32.toString().substring(ZERO, length).padEnd(length, '0'); + return shuffle(randomOtpDigits).join(''); +} diff --git a/src/core/decorators/validations/index.ts b/src/core/decorators/validations/index.ts index ff7bd09..be49db6 100644 --- a/src/core/decorators/validations/index.ts +++ b/src/core/decorators/validations/index.ts @@ -1 +1,3 @@ // placeholder +export * from './is-above-18'; +export * from './is-valid-phone-number'; diff --git a/src/core/decorators/validations/is-above-18.ts b/src/core/decorators/validations/is-above-18.ts new file mode 100644 index 0000000..99dad2d --- /dev/null +++ b/src/core/decorators/validations/is-above-18.ts @@ -0,0 +1,42 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import moment from 'moment'; +const EIGHTTEEN = 18; +export function IsAbove18(validationOptions?: ValidationOptions) { + return (object: any, propertyName: string) => { + registerDecorator({ + name: 'IsAbove18', + target: object.constructor, + propertyName, + options: { + message: `${propertyName} must be above 18 years old`, + ...validationOptions, + }, + constraints: [], + validator: IsAbove18Constraint, + }); + }; +} + +@ValidatorConstraint({ name: 'IsAbove18' }) +export class IsAbove18Constraint implements ValidatorConstraintInterface { + validate(value: any, args: ValidationArguments) { + if (!value) return true; + + const dateOfBirth = moment(value); + + if (!dateOfBirth.isValid()) { + return false; + } + + const today = moment(); + const age = today.diff(dateOfBirth, 'years'); + + return age >= EIGHTTEEN; + } +} diff --git a/src/core/decorators/validations/is-valid-phone-number.ts b/src/core/decorators/validations/is-valid-phone-number.ts new file mode 100644 index 0000000..a9c4a9c --- /dev/null +++ b/src/core/decorators/validations/is-valid-phone-number.ts @@ -0,0 +1,40 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import * as libphonenumber from 'google-libphonenumber'; + +const phoneUtil = libphonenumber.PhoneNumberUtil.getInstance(); + +export function IsValidPhoneNumber(validationOptions?: ValidationOptions) { + return (object: any, propertyName: string) => { + registerDecorator({ + name: 'IsValidPhoneNumber', + target: object.constructor, + propertyName, + options: { + message: `${propertyName} must be valid mobile number`, + ...validationOptions, + }, + constraints: [], + validator: IsValidPhoneNumberConstraint, + }); + }; +} + +@ValidatorConstraint({ name: 'IsValidPhoneNumber' }) +export class IsValidPhoneNumberConstraint implements ValidatorConstraintInterface { + validate(value: any, args: ValidationArguments) { + const countryCode = (args.object as any).countryCode; // +962; + const isoCountryCode = phoneUtil.getRegionCodeForCountryCode(+countryCode); // JO + try { + const parsedNumber = phoneUtil.parse(value, isoCountryCode); + return phoneUtil.isValidNumberForRegion(parsedNumber, isoCountryCode); + } catch (e) { + return false; + } + } +} diff --git a/src/core/pipes/validation.pipe.spec.ts b/src/core/pipes/validation.pipe.spec.ts index 2a6210b..4390da6 100644 --- a/src/core/pipes/validation.pipe.spec.ts +++ b/src/core/pipes/validation.pipe.spec.ts @@ -34,7 +34,7 @@ describe('ValidationPipe', () => { transform: true, validateCustomDecorators: true, stopAtFirstError: true, - forbidNonWhitelisted: false, + forbidNonWhitelisted: true, dismissDefaultMessages: true, enableDebugMessages: true, exceptionFactory: i18nValidationErrorFactory, diff --git a/src/core/pipes/validation.pipe.ts b/src/core/pipes/validation.pipe.ts index 5153b10..505fb03 100644 --- a/src/core/pipes/validation.pipe.ts +++ b/src/core/pipes/validation.pipe.ts @@ -9,7 +9,7 @@ export function buildValidationPipe(config: ConfigService): ValidationPipe { transform: true, validateCustomDecorators: true, stopAtFirstError: true, - forbidNonWhitelisted: false, + forbidNonWhitelisted: true, dismissDefaultMessages: true, enableDebugMessages: config.getOrThrow('NODE_ENV') === Environment.DEV, exceptionFactory: i18nValidationErrorFactory, diff --git a/src/customer/controllers/customer.controller.ts b/src/customer/controllers/customer.controller.ts new file mode 100644 index 0000000..2365633 --- /dev/null +++ b/src/customer/controllers/customer.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Patch, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AuthenticatedUser } from '~/common/decorators'; +import { AccessTokenGuard } from '~/common/guards'; +import { ResponseFactory } from '~/core/utils'; +import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; +import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response'; +import { CustomerService } from '../services'; + +@Controller('customers') +@ApiTags('Customers') +@ApiBearerAuth() +@UseGuards(AccessTokenGuard) +export class CustomerController { + constructor(private readonly customerService: CustomerService) {} + + @Patch('') + async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) { + const customer = await this.customerService.updateCustomer(sub, body); + + return ResponseFactory.data(new CustomerResponseDto(customer)); + } + + @Patch('settings/notifications') + async updateNotificationSettings( + @AuthenticatedUser() { sub }: IJwtPayload, + @Body() body: UpdateNotificationsSettingsRequestDto, + ) { + const notificationSettings = await this.customerService.updateNotificationSettings(sub, body); + + return ResponseFactory.data(new NotificationSettingsResponseDto(notificationSettings)); + } +} diff --git a/src/customer/controllers/index.ts b/src/customer/controllers/index.ts new file mode 100644 index 0000000..26207a4 --- /dev/null +++ b/src/customer/controllers/index.ts @@ -0,0 +1 @@ +export * from './customer.controller'; diff --git a/src/customer/customer.module.ts b/src/customer/customer.module.ts new file mode 100644 index 0000000..da7d8a8 --- /dev/null +++ b/src/customer/customer.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '~/auth/auth.module'; +import { CustomerController } from './controllers'; +import { Customer } from './entities'; +import { CustomerRepository } from './repositories/customer.repository'; +import { CustomerService } from './services'; + +@Module({ + imports: [TypeOrmModule.forFeature([Customer]), AuthModule], + controllers: [CustomerController], + providers: [CustomerService, CustomerRepository], +}) +export class CustomerModule {} diff --git a/src/customer/dtos/request/index.ts b/src/customer/dtos/request/index.ts new file mode 100644 index 0000000..a06f59b --- /dev/null +++ b/src/customer/dtos/request/index.ts @@ -0,0 +1,2 @@ +export * from './update-customer.request.dto'; +export * from './update-notifications-settings.request.dto'; diff --git a/src/customer/dtos/request/update-customer.request.dto.ts b/src/customer/dtos/request/update-customer.request.dto.ts new file mode 100644 index 0000000..c81f8a7 --- /dev/null +++ b/src/customer/dtos/request/update-customer.request.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { IsAbove18 } from '~/core/decorators/validations'; +export class UpdateCustomerRequestDto { + @ApiProperty({ example: 'John' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) + @IsOptional() + firstName!: string; + + @ApiProperty({ example: 'Doe' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) + @IsOptional() + lastName!: string; + + @ApiProperty({ example: 'JO' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) }) + @IsOptional() + countryOfResidence!: string; + + @ApiProperty({ example: '2021-01-01' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) + @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) + @IsOptional() + dateOfBirth!: Date; +} diff --git a/src/customer/dtos/request/update-notifications-settings.request.dto.ts b/src/customer/dtos/request/update-notifications-settings.request.dto.ts new file mode 100644 index 0000000..c67eb75 --- /dev/null +++ b/src/customer/dtos/request/update-notifications-settings.request.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateNotificationsSettingsRequestDto { + @ApiProperty() + @IsBoolean() + @IsOptional() + isEmailEnabled!: boolean; + + @ApiProperty() + @IsBoolean() + @IsOptional() + isPushEnabled!: boolean; + + @ApiProperty() + @IsBoolean() + @IsOptional() + isSmsEnabled!: boolean; +} diff --git a/src/customer/dtos/response/customer-response.dto.ts b/src/customer/dtos/response/customer-response.dto.ts new file mode 100644 index 0000000..4c2ef30 --- /dev/null +++ b/src/customer/dtos/response/customer-response.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Customer } from '~/customer/entities'; + +export class CustomerResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty() + customerStatus!: string; + + @ApiProperty() + rejectionReason!: string; + + @ApiProperty() + firstName!: string; + + @ApiProperty() + lastName!: string; + + @ApiProperty() + dateOfBirth!: Date; + + @ApiProperty() + nationalId!: string; + + @ApiProperty() + nationaIdExpiry!: Date; + + @ApiProperty() + countryOfResidence!: string; + + @ApiProperty() + sourceOfIncome!: string; + + @ApiProperty() + profession!: string; + + @ApiProperty() + professionType!: string; + + @ApiProperty() + isPep!: boolean; + + @ApiProperty() + gender!: string; + + @ApiProperty() + isJunior!: boolean; + + @ApiProperty() + isGuardian!: boolean; + + constructor(customer: Customer) { + this.id = customer.id; + this.customerStatus = customer.customerStatus; + this.rejectionReason = customer.rejectionReason; + this.firstName = customer.firstName; + this.lastName = customer.lastName; + this.dateOfBirth = customer.dateOfBirth; + this.nationalId = customer.nationalId; + this.nationaIdExpiry = customer.nationaIdExpiry; + this.countryOfResidence = customer.countryOfResidence; + this.sourceOfIncome = customer.sourceOfIncome; + this.profession = customer.profession; + this.professionType = customer.professionType; + this.isPep = customer.isPep; + this.gender = customer.gender; + this.isJunior = customer.isJunior; + this.isGuardian = customer.isGuardian; + } +} diff --git a/src/customer/dtos/response/index.ts b/src/customer/dtos/response/index.ts new file mode 100644 index 0000000..71f8a76 --- /dev/null +++ b/src/customer/dtos/response/index.ts @@ -0,0 +1,2 @@ +export * from './customer-response.dto'; +export * from './notification-settings.response.dto'; diff --git a/src/customer/dtos/response/notification-settings.response.dto.ts b/src/customer/dtos/response/notification-settings.response.dto.ts new file mode 100644 index 0000000..f7a5e6f --- /dev/null +++ b/src/customer/dtos/response/notification-settings.response.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserNotificationSettings } from '~/auth/entities'; + +export class NotificationSettingsResponseDto { + @ApiProperty() + isEmailEnabled!: boolean; + + @ApiProperty() + isPushEnabled!: boolean; + + @ApiProperty() + isSmsEnabled!: boolean; + + constructor(notificationSettings: UserNotificationSettings) { + this.isEmailEnabled = notificationSettings.isEmailEnabled; + this.isPushEnabled = notificationSettings.isPushEnabled; + this.isSmsEnabled = notificationSettings.isSmsEnabled; + } +} diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts new file mode 100644 index 0000000..6c6b88d --- /dev/null +++ b/src/customer/entities/customer.entity.ts @@ -0,0 +1,75 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '~/auth/entities'; + +@Entity() +export class Customer extends BaseEntity { + @PrimaryColumn('uuid') + id!: string; + + @Column('varchar', { length: 255, default: 'PENDING', name: 'customer_status' }) + customerStatus!: string; + + @Column('text', { nullable: true, name: 'rejection_reason' }) + rejectionReason!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'first_name' }) + firstName!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'last_name' }) + lastName!: string; + + @Column('date', { nullable: true, name: 'date_of_birth' }) + dateOfBirth!: Date; + + @Column('varchar', { length: 255, nullable: true, name: 'national_id' }) + nationalId!: string; + + @Column('date', { nullable: true, name: 'national_id_expiry' }) + nationaIdExpiry!: Date; + + @Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' }) + countryOfResidence!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'source_of_income' }) + sourceOfIncome!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'profession' }) + profession!: string; + + @Column('varchar', { length: 255, nullable: true, name: 'profession_type' }) + professionType!: string; + + @Column('boolean', { default: false, name: 'is_pep' }) + isPep!: boolean; + + @Column('varchar', { length: 255, nullable: true, name: 'gender' }) + gender!: string; + + @Column('boolean', { default: false, name: 'is_junior' }) + isJunior!: boolean; + + @Column('boolean', { default: false, name: 'is_guardian' }) + isGuardian!: boolean; + + @Column('varchar', { name: 'user_id' }) + userId!: string; + + @OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User; + + @CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/src/customer/entities/index.ts b/src/customer/entities/index.ts new file mode 100644 index 0000000..342b1d3 --- /dev/null +++ b/src/customer/entities/index.ts @@ -0,0 +1 @@ +export * from './customer.entity'; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts new file mode 100644 index 0000000..f0ddb5d --- /dev/null +++ b/src/customer/repositories/customer.repository.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsWhere, Repository } from 'typeorm'; +import { UpdateCustomerRequestDto } from '../dtos/request'; +import { Customer } from '../entities'; + +@Injectable() +export class CustomerRepository { + constructor(@InjectRepository(Customer) private readonly customerRepository: Repository) {} + + updateCustomer(id: string, data: UpdateCustomerRequestDto) { + return this.customerRepository.update(id, data); + } + + findOne(where: FindOptionsWhere) { + return this.customerRepository.findOne({ where }); + } +} diff --git a/src/customer/repositories/index.ts b/src/customer/repositories/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts new file mode 100644 index 0000000..f732814 --- /dev/null +++ b/src/customer/services/customer.service.ts @@ -0,0 +1,26 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { UserService } from '~/auth/services/user.service'; +import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; +import { Customer } from '../entities'; +import { CustomerRepository } from '../repositories/customer.repository'; + +@Injectable() +export class CustomerService { + constructor(private readonly userService: UserService, private readonly customerRepository: CustomerRepository) {} + updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto) { + return this.userService.updateNotificationSettings(userId, data); + } + + async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise { + await this.customerRepository.updateCustomer(userId, data); + return this.findCustomerById(userId); + } + + async findCustomerById(id: string) { + const customer = await this.customerRepository.findOne({ id }); + if (!customer) { + throw new BadRequestException('CUSTOMER.NOT_FOUND'); + } + return customer; + } +} diff --git a/src/customer/services/index.ts b/src/customer/services/index.ts new file mode 100644 index 0000000..0c04669 --- /dev/null +++ b/src/customer/services/index.ts @@ -0,0 +1 @@ +export * from './customer.service'; diff --git a/src/db/migrations/1733206728721-create-user-entity.ts b/src/db/migrations/1733206728721-create-user-entity.ts new file mode 100644 index 0000000..be4c9a6 --- /dev/null +++ b/src/db/migrations/1733206728721-create-user-entity.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserEntity1733206728721 implements MigrationInterface { + name = 'CreateUserEntity1733206728721'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "users" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "email" character varying(255), + "phone_number" character varying(255) NOT NULL, + "country_code" character varying(10) NOT NULL, + "password" character varying(255), + "salt" character varying(255), + "google_id" character varying(255), + "apple_id" character varying(255), + "is_profile_completed" boolean NOT NULL DEFAULT false, + "roles" text array, + "profile_picture_id" uuid, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "REL_02ec15de199e79a0c46869895f" UNIQUE ("profile_picture_id"), + CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`, + ); + + await queryRunner.query( + `ALTER TABLE "users" ADD CONSTRAINT "FK_02ec15de199e79a0c46869895f4" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_02ec15de199e79a0c46869895f4"`); + await queryRunner.query(`DROP TABLE "users"`); + } +} diff --git a/src/db/migrations/1733209041336-create-otp-entity.ts b/src/db/migrations/1733209041336-create-otp-entity.ts new file mode 100644 index 0000000..ff2308c --- /dev/null +++ b/src/db/migrations/1733209041336-create-otp-entity.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateOtpEntity1733209041336 implements MigrationInterface { + name = 'CreateOtpEntity1733209041336'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "otp" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "value" character varying(255) NOT NULL, + "scope" character varying(255) NOT NULL, + "otp_type" character varying(255) NOT NULL, + "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "user_id" uuid NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_32556d9d7b22031d7d0e1fd6723" PRIMARY KEY ("id"))`); + + await queryRunner.query(`CREATE INDEX "IDX_6427c192ef35355ebac18fb683" ON "otp" ("scope") `); + await queryRunner.query(`CREATE INDEX "IDX_258d028d322ea3b856bf9f12f2" ON "otp" ("user_id") `); + await queryRunner.query( + `ALTER TABLE "otp" ADD CONSTRAINT "FK_258d028d322ea3b856bf9f12f25" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "otp" DROP CONSTRAINT "FK_258d028d322ea3b856bf9f12f25"`); + await queryRunner.query(`DROP INDEX "public"."IDX_258d028d322ea3b856bf9f12f2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6427c192ef35355ebac18fb683"`); + await queryRunner.query(`DROP TABLE "otp"`); + } +} diff --git a/src/db/migrations/1733231692252-create-notification-settings-table.ts b/src/db/migrations/1733231692252-create-notification-settings-table.ts new file mode 100644 index 0000000..a53d572 --- /dev/null +++ b/src/db/migrations/1733231692252-create-notification-settings-table.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateNotificationSettingsTable1733231692252 implements MigrationInterface { + name = 'CreateNotificationSettingsTable1733231692252'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_notification_settings" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "is_email_enabled" boolean NOT NULL DEFAULT false, + "is_push_enabled" boolean NOT NULL DEFAULT false, + "is_sms_enabled" boolean NOT NULL DEFAULT false, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "user_id" uuid, CONSTRAINT "REL_52182ffd0f785e8256f8fcb4fd" UNIQUE ("user_id"), + CONSTRAINT "PK_a195de67d093e096152f387afbd" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "user_notification_settings" ADD CONSTRAINT "FK_52182ffd0f785e8256f8fcb4fd6" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_notification_settings" DROP CONSTRAINT "FK_52182ffd0f785e8256f8fcb4fd6"`, + ); + await queryRunner.query(`DROP TABLE "user_notification_settings"`); + } +} diff --git a/src/db/migrations/1733298524771-create-customer-entity.ts b/src/db/migrations/1733298524771-create-customer-entity.ts new file mode 100644 index 0000000..588cc7e --- /dev/null +++ b/src/db/migrations/1733298524771-create-customer-entity.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCustomerEntity1733298524771 implements MigrationInterface { + name = 'CreateCustomerEntity1733298524771'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "customer" + ("id" uuid NOT NULL, + "customer_status" character varying(255) NOT NULL DEFAULT 'PENDING', + "rejection_reason" text, + "first_name" character varying(255), + "last_name" character varying(255), + "date_of_birth" date, + "national_id" character varying(255), + "national_id_expiry" date, + "country_of_residence" character varying(255), + "source_of_income" character varying(255), + "profession" character varying(255), + "profession_type" character varying(255), + "is_pep" boolean NOT NULL DEFAULT false, + "gender" character varying(255), + "is_junior" boolean NOT NULL DEFAULT false, + "is_guardian" boolean NOT NULL DEFAULT false, + "user_id" uuid NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "REL_5d1f609371a285123294fddcf3" UNIQUE ("user_id"), + CONSTRAINT "PK_a7a13f4cacb744524e44dfdad32" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "customer" ADD CONSTRAINT "FK_5d1f609371a285123294fddcf3a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customer" DROP CONSTRAINT "FK_5d1f609371a285123294fddcf3a"`); + await queryRunner.query(`DROP TABLE "customer"`); + } +} diff --git a/src/db/migrations/1733314952318-create-device-entity.ts b/src/db/migrations/1733314952318-create-device-entity.ts new file mode 100644 index 0000000..7677691 --- /dev/null +++ b/src/db/migrations/1733314952318-create-device-entity.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateDeviceEntity1733314952318 implements MigrationInterface { + name = 'CreateDeviceEntity1733314952318'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "devices" + ("deviceId" character varying(255) NOT NULL, + "user_id" uuid NOT NULL, + "device_name" character varying, + "public_key" character varying, + "last_access_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_666c9b59efda8ca85b29157152c" PRIMARY KEY ("deviceId"))`, + ); + await queryRunner.query( + `ALTER TABLE "devices" ADD CONSTRAINT "FK_5e9bee993b4ce35c3606cda194c" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "devices" DROP CONSTRAINT "FK_5e9bee993b4ce35c3606cda194c"`); + await queryRunner.query(`DROP TABLE "devices"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 0bf4de3..0629239 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -1 +1,6 @@ export * from './1732434281561-create-document-entity'; +export * from './1733206728721-create-user-entity'; +export * from './1733209041336-create-otp-entity'; +export * from './1733231692252-create-notification-settings-table'; +export * from './1733298524771-create-customer-entity'; +export * from './1733314952318-create-device-entity'; diff --git a/src/document/controllers/document.controller.ts b/src/document/controllers/document.controller.ts index 0c89700..f6502c7 100644 --- a/src/document/controllers/document.controller.ts +++ b/src/document/controllers/document.controller.ts @@ -8,7 +8,7 @@ import { DocumentMetaResponseDto } from '../dtos/response'; import { DocumentType } from '../enums'; import { DocumentService } from '../services'; @Controller('document') -@ApiTags('document') +@ApiTags('Document') export class DocumentController { constructor(private readonly documentService: DocumentService) {} diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 85c2ba0..6a9f156 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -1,4 +1,5 @@ -import { Column, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Column, Entity, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { User } from '~/auth/entities'; import { DocumentType } from '../enums'; @Entity('documents') @@ -15,6 +16,9 @@ export class Document { @Column({ type: 'varchar', length: 255 }) documentType!: DocumentType; + @OneToOne(() => User, (user) => user.profilePicture, { onDelete: 'CASCADE' }) + user!: User; + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) updatedAt!: Date; diff --git a/src/i18n/ar/validation.json b/src/i18n/ar/validation.json index c15ae9a..4c2ffea 100644 --- a/src/i18n/ar/validation.json +++ b/src/i18n/ar/validation.json @@ -22,5 +22,7 @@ "IsLengthIn": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون {constraints.0} حروف/ارقام", "Matches": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يتطابق مع النمط", "IsValidExpiryDate": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون تاريخ انتهاء بطاقه صحيح", - "IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون باللغة الانجليزية" + "IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون باللغة الانجليزية", + "IsAbove18": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون فوق 18 سنة", + "IsValidPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون رقم هاتف صحيح" } diff --git a/src/i18n/en/validation.json b/src/i18n/en/validation.json index 02c227c..5ce9fcd 100644 --- a/src/i18n/en/validation.json +++ b/src/i18n/en/validation.json @@ -22,5 +22,7 @@ "IsLengthIn": "$t({path}.PROPERTY_MAPPINGS.{property}) must be {constraints.0} characters/digits", "Matches": "$t({path}.PROPERTY_MAPPINGS.{property}) invalid format", "IsValidExpiryDate": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid expiry date", - "IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) must be in English" + "IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) must be in English", + "IsAbove18": "$t({path}.PROPERTY_MAPPINGS.{property}) must be above 18 years", + "IsValidPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid phone number" } diff --git a/src/main.ts b/src/main.ts index e4aca43..7fdece5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,6 @@ async function bootstrap() { if (config.getOrThrow('NODE_ENV') === 'dev') { SwaggerModule.setup(config.getOrThrow('SWAGGER_API_DOCS_PATH'), app, swaggerDocument, { swaggerOptions: { - tagsSorter: 'alpha', docExpansion: 'none', }, });