From 4c533fa2b51ae2c516101373e115679dfef82988 Mon Sep 17 00:00:00 2001 From: Md Azizul Hakim Date: Wed, 30 Oct 2024 17:10:30 +0600 Subject: [PATCH] added queue, email also minor restructure --- README.md | 4 +- package-lock.json | 479 +++++++++++++++++- package.json | 10 +- src/app.module.ts | 12 + .../decorators/permissions.decorator.ts | 2 +- src/config/bull-redis.config.ts | 38 ++ src/database/seeders/RoleSeeder.ts | 2 +- src/modules/auth/auth.controller.spec.ts | 20 + src/modules/auth/auth.controller.ts | 17 +- src/modules/auth/auth.module.ts | 2 + src/modules/email/email.module.ts | 19 + src/modules/email/email.processor.spec.ts | 224 ++++++++ src/modules/email/email.processor.ts | 33 ++ src/modules/email/email.service.spec.ts | 146 ++++++ src/modules/email/email.service.ts | 63 +++ src/modules/email/enums/email.enum.ts | 7 + .../email/templates/send-login-alert.hbs | 3 + src/modules/email/types.d.ts | 16 + .../permission}/enums/permission.enum.ts | 0 src/{ => modules/role}/enums/role.enum.ts | 0 src/modules/role/role.controller.ts | 4 +- src/modules/user/user.controller.ts | 2 +- src/modules/user/user.service.ts | 18 +- 23 files changed, 1097 insertions(+), 24 deletions(-) create mode 100644 src/config/bull-redis.config.ts create mode 100644 src/modules/email/email.module.ts create mode 100644 src/modules/email/email.processor.spec.ts create mode 100644 src/modules/email/email.processor.ts create mode 100644 src/modules/email/email.service.spec.ts create mode 100644 src/modules/email/email.service.ts create mode 100644 src/modules/email/enums/email.enum.ts create mode 100644 src/modules/email/templates/send-login-alert.hbs create mode 100644 src/modules/email/types.d.ts rename src/{ => modules/permission}/enums/permission.enum.ts (100%) rename src/{ => modules/role}/enums/role.enum.ts (100%) diff --git a/README.md b/README.md index be857b9..ac94c5d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## About this project -`ims-nest-api-starter` is a backend API starter template using [NestJS](https://nestjs.com/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/) and [MikroORM](https://mikro-orm.io/) designed for scalable applications. +`ims-nest-api-starter` is a backend API starter template using [NestJS](https://nestjs.com/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/), [BullMQ](https://bullmq.io/) and [MikroORM](https://mikro-orm.io/) designed for scalable applications. ### Key Features @@ -14,6 +14,8 @@ - **Authorization**: Role- and permission-based access control to manage user privileges. - **Caching Support**: Integrated Redis caching for enhanced performance. - **Database Management**: MikroORM setup with PostgreSQL for efficient data handling. +- **Queue Management**: BullMQ for asynchronous tasks and event-driven architecture. +- **Email Notification**: Send emails using [Nodemailer](https://nodemailer.com/) with BullMQ asynchronously. - **XSECURITY**: An added security layer that safeguards APIs against unauthorized access, ensuring data protection and integrity. ## Getting Started Guide Without Docker diff --git a/package-lock.json b/package-lock.json index d4aac02..23a1383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "ims-nest-api-starter", "version": "0.0.1", - "license": "UNLICENSED", + "license": "MIT", "dependencies": { "@faker-js/faker": "^9.0.3", "@mikro-orm/cli": "^6.3.13", @@ -19,6 +19,7 @@ "@mikro-orm/seeder": "^6.3.13", "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/axios": "^3.0.3", + "@nestjs/bullmq": "^10.2.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.0.0", @@ -29,12 +30,15 @@ "@nestjs/terminus": "^10.2.3", "@nestjs/throttler": "^6.2.1", "bcrypt": "^5.1.1", + "bullmq": "^5.21.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", + "handlebars": "^4.7.8", "ims-nest-api-starter": "file:", "ioredis": "^5.4.1", "nestjs-command": "^3.1.4", + "nodemailer": "^6.9.16", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.13.0", @@ -51,11 +55,13 @@ "@types/ioredis-mock": "^8.2.5", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/nodemailer": "^6.4.16", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", @@ -1814,6 +1820,78 @@ "@mikro-orm/core": "^6.0.0" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@nestjs-modules/ioredis": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@nestjs-modules/ioredis/-/ioredis-2.0.2.tgz", @@ -1917,6 +1995,44 @@ "rxjs": "^6.0.0 || ^7.0.0" } }, + "node_modules/@nestjs/bull-shared": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.2.tgz", + "integrity": "sha512-bMIEILYYovQWfdz6fCSTgqb/zuKyGmNSc7guB56MiZVW84JloUHb8330nNh3VWaamJKGtUzawbEoG2VR3uVeOg==", + "dependencies": { + "tslib": "2.8.0" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/bull-shared/node_modules/tslib": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" + }, + "node_modules/@nestjs/bullmq": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.2.tgz", + "integrity": "sha512-1RXhR7+XK6uXaw9uNH5hP9bcW5Vzkpc4lX7t7sUC23N9XH2CMH6uUm0I14T5KkvMKkj0VXj0GY+Ulh3pCtdwbA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^10.2.2", + "tslib": "2.8.0" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@nestjs/bullmq/node_modules/tslib": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", + "license": "0BSD" + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -2796,6 +2912,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", @@ -3939,6 +4065,34 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/bullmq": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.21.2.tgz", + "integrity": "sha512-LPuNoGaDc5CON2X6h4cJ2iVfd+B+02xubFU+IB/fyJHd+/HqUZRqnlYryUCAuhVHBhUKtA6oyVdJxqSa62i+og==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.6.0", + "ioredis": "^5.4.1", + "msgpackr": "^1.10.1", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^9.0.0" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -4398,6 +4552,144 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/copyfiles": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", + "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.0.5", + "minimatch": "^3.0.3", + "mkdirp": "^1.0.4", + "noms": "0.0.0", + "through2": "^2.0.1", + "untildify": "^4.0.0", + "yargs": "^16.1.0" + }, + "bin": { + "copyfiles": "copyfiles", + "copyup": "copyfiles" + } + }, + "node_modules/copyfiles/node_modules/brace-expansion": { + "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", + "concat-map": "0.0.1" + } + }, + "node_modules/copyfiles/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/copyfiles/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/copyfiles/node_modules/minimatch": { + "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" + }, + "engines": { + "node": "*" + } + }, + "node_modules/copyfiles/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/copyfiles/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/copyfiles/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/copyfiles/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4468,6 +4760,17 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -5877,6 +6180,36 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -7532,6 +7865,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -7770,6 +8111,35 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/msgpackr": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.0.tgz", + "integrity": "sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw==", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "1.4.4-lts.1", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", @@ -7810,8 +8180,7 @@ "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/nestjs-command": { "version": "3.1.4", @@ -7833,8 +8202,7 @@ "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, "node_modules/node-addon-api": { "version": "5.1.0", @@ -7869,6 +8237,20 @@ } } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7881,6 +8263,53 @@ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", + "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/noms": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", + "integrity": "sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==", + "dev": true, + "license": "ISC", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "~1.0.31" + } + }, + "node_modules/noms/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/noms/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/noms/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -9717,6 +10146,17 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, "node_modules/tildify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", @@ -10010,6 +10450,19 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/uid": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", @@ -10068,6 +10521,16 @@ "node": ">= 0.8" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", @@ -10332,6 +10795,12 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/package.json b/package.json index a4ff56a..c3c7212 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "description": "the Nest API starter of innovix Matrix System", "author": "Azizul Hakim ", "private": true, - "license": "UNLICENSED", + "license": "MIT", "scripts": { - "build": "nest build", + "build": "nest build && copyfiles -u 1 src/**/*.hbs dist", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", @@ -40,6 +40,7 @@ "@mikro-orm/seeder": "^6.3.13", "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/axios": "^3.0.3", + "@nestjs/bullmq": "^10.2.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.0.0", @@ -50,12 +51,15 @@ "@nestjs/terminus": "^10.2.3", "@nestjs/throttler": "^6.2.1", "bcrypt": "^5.1.1", + "bullmq": "^5.21.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", + "handlebars": "^4.7.8", "ims-nest-api-starter": "file:", "ioredis": "^5.4.1", "nestjs-command": "^3.1.4", + "nodemailer": "^6.9.16", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.13.0", @@ -72,11 +76,13 @@ "@types/ioredis-mock": "^8.2.5", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/nodemailer": "^6.4.16", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 2af2f10..6ff80b9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,16 +8,19 @@ import { CommandModule } from 'nestjs-command'; import { AppController } from './app.controller'; import { CreateModuleCommand } from './commands/create-module.command'; import { XSecureInstallCommand } from './commands/xsecurity.command'; +import bullRedisConfig from './config/bull-redis.config'; import mikroOrmConfig from './config/mikro-orm.config'; import redisConfig from './config/redis.config'; import { XSecurityMiddleware } from './middlewares/xsecurity.middleware'; import { AuthModule } from './modules/auth/auth.module'; import { CacheModule } from './modules/cache/cache.module'; +import { EmailModule } from './modules/email/email.module'; import { HealthModule } from './modules/health/health.module'; import { MiscModule } from './modules/misc/misc.module'; import { PermissionModule } from './modules/permission/permission.module'; import { RoleModule } from './modules/role/role.module'; import { UserModule } from './modules/user/user.module'; +import { BullModule } from '@nestjs/bullmq'; @Module({ imports: [ @@ -47,10 +50,19 @@ import { UserModule } from './modules/user/user.module'; useFactory: (configService: ConfigService) => redisConfig(configService), inject: [ConfigService], }), + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + connection: bullRedisConfig(configService), + defaultJobOptions: { attempts: 3, removeOnComplete: true }, + }), + }), CommandModule, HealthModule, MiscModule, CacheModule, + EmailModule, PermissionModule, RoleModule, UserModule, diff --git a/src/common/decorators/permissions.decorator.ts b/src/common/decorators/permissions.decorator.ts index a616681..fb239cf 100644 --- a/src/common/decorators/permissions.decorator.ts +++ b/src/common/decorators/permissions.decorator.ts @@ -1,5 +1,5 @@ import { SetMetadata } from '@nestjs/common'; -import { PermissionName } from '../../enums/permission.enum'; +import { PermissionName } from '../../modules/permission/enums/permission.enum'; export const Permissions = (...permissions: string[]) => SetMetadata(PermissionName.PERMISSIONS_KEY, permissions); diff --git a/src/config/bull-redis.config.ts b/src/config/bull-redis.config.ts new file mode 100644 index 0000000..11d7453 --- /dev/null +++ b/src/config/bull-redis.config.ts @@ -0,0 +1,38 @@ +import { ConfigService } from '@nestjs/config'; +import { config } from 'dotenv'; +import { RedisOptions } from 'ioredis'; +import { getConfigValue } from '../utils/helper'; + +// Load environment variables for CLI usage +config(); + +export class RedisConfig { + constructor(private readonly configService?: ConfigService) {} + + configureOptions(): RedisOptions { + return { + host: getConfigValue( + 'REDIS_HOST', + 'localhost', + this.configService, + ), + port: Number( + getConfigValue('REDIS_PORT', '6379', this.configService), + ), + username: getConfigValue( + 'REDIS_USERNAME', + '', + this.configService, + ), + password: getConfigValue( + 'REDIS_PASSWORD', + '', + this.configService, + ), + db: Number(getConfigValue('REDIS_DB', '0', this.configService)), + }; + } +} + +export default (configService?: ConfigService) => + new RedisConfig(configService).configureOptions(); diff --git a/src/database/seeders/RoleSeeder.ts b/src/database/seeders/RoleSeeder.ts index d6b76e7..fe2ceaa 100644 --- a/src/database/seeders/RoleSeeder.ts +++ b/src/database/seeders/RoleSeeder.ts @@ -1,6 +1,6 @@ import type { EntityManager, EntityRepository } from '@mikro-orm/core'; import { Seeder } from '@mikro-orm/seeder'; -import { RoleName } from '../../enums/role.enum'; +import { RoleName } from '../../modules/role/enums/role.enum'; import { Permission } from '../../modules/permission/entities/permission.entity'; import { Role } from '../../modules/role/entities/role.entity'; diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 565ba2d..268c689 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException, HttpStatus } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Response } from 'express'; +import { EmailService } from '../email/email.service'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; @@ -8,12 +9,17 @@ import { LoginDto } from './dto/login.dto'; describe('AuthController', () => { let controller: AuthController; let authService: jest.Mocked; + let emailService: jest.Mocked; beforeEach(async () => { const mockAuthService = { login: jest.fn(), }; + const mockEmailService = { + sendEmail: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], providers: [ @@ -21,11 +27,16 @@ describe('AuthController', () => { provide: AuthService, useValue: mockAuthService, }, + { + provide: EmailService, + useValue: mockEmailService, + }, ], }).compile(); controller = module.get(AuthController); authService = module.get(AuthService); + emailService = module.get(EmailService); }); it('should be defined', () => { @@ -61,6 +72,15 @@ describe('AuthController', () => { loginDto.email, loginDto.password, ); + expect(emailService.sendEmail).toHaveBeenCalledWith({ + key: expect.any(String), + to: mockAuthData.email, + subject: 'Login Alert', + options: { + name: mockAuthData.name, + loginAt: expect.any(Date), + }, + }); expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); expect(mockResponse.json).toHaveBeenCalledWith({ data: mockAuthData, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ed6ace8..da23df5 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -8,12 +8,17 @@ import { } from '@nestjs/common'; import { BaseController } from '../../common/controllers/base.controller'; import { ValidationExceptionFilter } from '../../common/filters/validation-exception.filter'; +import { EmailService } from '../email/email.service'; +import { EmailConfig } from '../email/enums/email.enum'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; @Controller('auth') export class AuthController extends BaseController { - constructor(private readonly authService: AuthService) { + constructor( + private readonly authService: AuthService, + private readonly emailService: EmailService, + ) { super(); } @@ -24,6 +29,16 @@ export class AuthController extends BaseController { loginDto.email, loginDto.password, ); + //send login alert email + await this.emailService.sendEmail({ + key: EmailConfig.SEND_LOGIN_ALERT_EMAIL.toString(), + to: authData.email, + subject: 'Login Alert', + options: { + name: authData.name, + loginAt: new Date(), + }, + }); return this.sendSuccessResponse( authData, 'Logged in successfully', diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 77e8d95..b8695a4 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; +import { EmailModule } from '../email/email.module'; import { UserModule } from '../user/user.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -19,6 +20,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; inject: [ConfigService], }), UserModule, + EmailModule, ], providers: [ConfigService, AuthService, JwtStrategy], controllers: [AuthController], diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts new file mode 100644 index 0000000..a92dc0c --- /dev/null +++ b/src/modules/email/email.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EmailProcessor } from './email.processor'; +import { EmailService } from './email.service'; +import { EmailConfig } from './enums/email.enum'; +import { BullModule } from '@nestjs/bullmq'; + +@Module({ + imports: [ + ConfigModule, + BullModule.registerQueue({ + name: EmailConfig.EMAIL_QUEUE, + }), + ], + controllers: [], + providers: [EmailService, EmailProcessor], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/src/modules/email/email.processor.spec.ts b/src/modules/email/email.processor.spec.ts new file mode 100644 index 0000000..9d12ffa --- /dev/null +++ b/src/modules/email/email.processor.spec.ts @@ -0,0 +1,224 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Job } from 'bullmq'; +import * as nodemailer from 'nodemailer'; +import { EmailProcessor } from './email.processor'; + +// Mock nodemailer +jest.mock('nodemailer', () => ({ + createTransport: jest.fn().mockReturnValue({ + sendMail: jest.fn(), + }), +})); + +describe('EmailProcessor', () => { + let processor: EmailProcessor; + let mockConfigService: Partial; + let mockTransporter: any; + + const mockSmtpConfig = { + SMTP_HOST: 'smtp.example.com', + SMTP_PORT: 587, + SMTP_USERNAME: 'test@example.com', + SMTP_PASSWORD: 'password123', + }; + + beforeEach(async () => { + (nodemailer.createTransport as jest.Mock).mockClear(); + // Create mock ConfigService + mockConfigService = { + get: jest.fn((key: string) => mockSmtpConfig[key]), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailProcessor, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + processor = module.get(EmailProcessor); + mockTransporter = (nodemailer.createTransport as jest.Mock)(); + + // Clear all mocks before each test + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(processor).toBeDefined(); + }); + + describe('constructor', () => { + it('should create nodemailer transport with correct config', () => { + new EmailProcessor(mockConfigService as ConfigService); + expect(nodemailer.createTransport).toHaveBeenCalledTimes(1); + expect(nodemailer.createTransport).toHaveBeenCalledWith({ + host: mockSmtpConfig.SMTP_HOST, + port: mockSmtpConfig.SMTP_PORT, + auth: { + user: mockSmtpConfig.SMTP_USERNAME, + pass: mockSmtpConfig.SMTP_PASSWORD, + }, + }); + }); + }); + + describe('handleSendLoginAlertEmail', () => { + const mockEmailPayload = { + to: 'recipient@example.com', + subject: 'Login Alert', + text: 'Login alert text', + html: '

Login alert html

', + }; + + const mockJob: Partial = { + data: mockEmailPayload, + }; + + it('should send email with correct options', async () => { + mockTransporter.sendMail.mockResolvedValueOnce({ messageId: 'test-id' }); + + await processor.process(mockJob as Job); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith({ + to: mockEmailPayload.to, + subject: mockEmailPayload.subject, + text: mockEmailPayload.text, + html: mockEmailPayload.html, + }); + }); + + it('should handle successful email sending', async () => { + const mockResponse = { messageId: 'test-id' }; + mockTransporter.sendMail.mockResolvedValueOnce(mockResponse); + + const result = await processor.process(mockJob as Job); + + expect(result).toEqual(mockResponse); + }); + + it('should handle email sending failure', async () => { + const mockError = new Error('Failed to send email'); + mockTransporter.sendMail.mockRejectedValueOnce(mockError); + + await expect(processor.process(mockJob as Job)).rejects.toThrow( + mockError, + ); + }); + + it('should handle invalid email payload', async () => { + const invalidJob: Partial = { + data: { + to: '', // Invalid email + subject: '', + }, + }; + + mockTransporter.sendMail.mockRejectedValueOnce( + new Error('Invalid email address'), + ); + + await expect(processor.process(invalidJob as Job)).rejects.toThrow(); + }); + }); + + describe('error handling', () => { + it('should handle transport creation failure', () => { + (nodemailer.createTransport as jest.Mock).mockImplementationOnce(() => { + throw new Error('Transport creation failed'); + }); + + expect(() => { + new EmailProcessor(mockConfigService as ConfigService); + }).toThrow('Transport creation failed'); + }); + + it('should handle missing job data', async () => { + const invalidJob: Partial = { + data: undefined, + }; + + await expect(processor.process(invalidJob as Job)).rejects.toThrow(); + }); + + it('should handle null values in email payload', async () => { + const jobWithNullValues: Partial = { + data: { + to: 'test@example.com', + subject: null, + text: null, + html: null, + }, + }; + + await processor.process(jobWithNullValues as Job); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith({ + to: 'test@example.com', + subject: null, + text: null, + html: null, + }); + }); + }); + + // Testing different email configurations + describe('email configurations', () => { + it('should handle multiple recipients', async () => { + const multipleRecipientsJob: Partial = { + data: { + to: ['recipient1@example.com', 'recipient2@example.com'], + subject: 'Test', + html: '

Test

', + }, + }; + + await processor.process(multipleRecipientsJob as Job); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ['recipient1@example.com', 'recipient2@example.com'], + }), + ); + }); + + it('should handle HTML-only emails', async () => { + const htmlOnlyJob: Partial = { + data: { + to: 'test@example.com', + subject: 'Test', + html: '

Test

', + }, + }; + + await processor.process(htmlOnlyJob as Job); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith({ + to: 'test@example.com', + subject: 'Test', + html: '

Test

', + }); + }); + + it('should handle text-only emails', async () => { + const textOnlyJob: Partial = { + data: { + to: 'test@example.com', + subject: 'Test', + text: 'Test message', + }, + }; + + await processor.process(textOnlyJob as Job); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith({ + to: 'test@example.com', + subject: 'Test', + text: 'Test message', + }); + }); + }); +}); diff --git a/src/modules/email/email.processor.ts b/src/modules/email/email.processor.ts new file mode 100644 index 0000000..8759b31 --- /dev/null +++ b/src/modules/email/email.processor.ts @@ -0,0 +1,33 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { ConfigService } from '@nestjs/config'; +import { Job } from 'bullmq'; +import * as nodemailer from 'nodemailer'; +import { MailOptions } from 'nodemailer/lib/json-transport'; +import { EmailConfig } from './enums/email.enum'; + +@Processor(EmailConfig.EMAIL_QUEUE) +export class EmailProcessor extends WorkerHost { + private transporter: nodemailer.Transporter; + constructor(configService: ConfigService) { + super(); + this.transporter = nodemailer.createTransport({ + host: configService.get('SMTP_HOST'), + port: configService.get('SMTP_PORT'), + auth: { + user: configService.get('SMTP_USERNAME'), + pass: configService.get('SMTP_PASSWORD'), + }, + }); + } + async process(job: Job) { + const emailPayload: EmailTransportPacket = job.data; + const { to, subject, text, html } = emailPayload; + const mailOptions: MailOptions = { + to, + subject, + text, + html, + }; + return this.transporter.sendMail(mailOptions); + } +} diff --git a/src/modules/email/email.service.spec.ts b/src/modules/email/email.service.spec.ts new file mode 100644 index 0000000..c1e1dc2 --- /dev/null +++ b/src/modules/email/email.service.spec.ts @@ -0,0 +1,146 @@ +import { getQueueToken } from '@nestjs/bullmq'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Queue } from 'bullmq'; +import * as fs from 'fs'; +import { EmailService } from './email.service'; +import { EmailConfig } from './enums/email.enum'; + +// Mock fs.promises +jest.mock('fs', () => ({ + promises: { + readFile: jest.fn(), + }, + existsSync: jest.fn(), +})); + +describe('EmailService', () => { + let service: EmailService; + let mockQueue: Partial; + + const mockTemplate = `

Hello {{name}}!

+

Welcome to {{company}}

`; + + beforeEach(async () => { + // Create mock Queue + mockQueue = { + add: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { + provide: getQueueToken(EmailConfig.EMAIL_QUEUE), + useValue: mockQueue, + }, + ], + }).compile(); + + service = module.get(EmailService); + + // Reset all mocks before each test + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('sendEmail', () => { + const mockEmailPayload = { + key: EmailConfig.SEND_LOGIN_ALERT_EMAIL.toString(), + to: 'test@example.com', + subject: 'Welcome Email', + options: { + name: 'John', + company: 'ACME', + }, + }; + + beforeEach(() => { + // Mock filesystem operations + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.promises.readFile as jest.Mock).mockResolvedValue(mockTemplate); + }); + + it('should successfully send an email', async () => { + await service.sendEmail(mockEmailPayload); + + // Check if queue.add was called with correct parameters + expect(mockQueue.add).toHaveBeenCalledWith( + mockEmailPayload.key, + expect.objectContaining({ + to: mockEmailPayload.to, + subject: mockEmailPayload.subject, + html: expect.any(String), + }), + ); + }); + + it('should compile template with provided options', async () => { + await service.sendEmail(mockEmailPayload); + + const expectedHtml = `

Hello John!

+

Welcome to ACME

`; + + expect(mockQueue.add).toHaveBeenCalledWith( + mockEmailPayload.key, + expect.objectContaining({ + html: expectedHtml, + }), + ); + }); + + it('should throw error when template is not found', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + await expect(service.sendEmail(mockEmailPayload)).rejects.toThrow( + 'Template not found', + ); + }); + + it('should throw error when template compilation fails', async () => { + (fs.promises.readFile as jest.Mock).mockRejectedValue( + new Error('Read file error'), + ); + + await expect(service.sendEmail(mockEmailPayload)).rejects.toThrow( + 'Failed to compile email template', + ); + }); + }); + + // Test error cases + describe('error handling', () => { + it('should throw error when queue add fails', async () => { + const mockError = new Error('Queue error'); + (mockQueue.add as jest.Mock).mockRejectedValue(mockError); + + const mockEmailPayload = { + key: EmailConfig.SEND_LOGIN_ALERT_EMAIL.toString(), + to: 'test@example.com', + subject: 'Test', + options: {}, + }; + + await expect(service.sendEmail(mockEmailPayload)).rejects.toThrow(); + }); + + it('should handle invalid template format', async () => { + (fs.promises.readFile as jest.Mock).mockResolvedValue('{{invalid'); + + const mockEmailPayload = { + key: EmailConfig.SEND_LOGIN_ALERT_EMAIL.toString(), + to: 'test@example.com', + subject: 'Test', + options: {}, + }; + + await expect(service.sendEmail(mockEmailPayload)).rejects.toThrow(); + }); + }); +}); diff --git a/src/modules/email/email.service.ts b/src/modules/email/email.service.ts new file mode 100644 index 0000000..0e08414 --- /dev/null +++ b/src/modules/email/email.service.ts @@ -0,0 +1,63 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import * as fs from 'fs'; +import * as handlebars from 'handlebars'; +import * as path from 'path'; +import { EmailConfig } from './enums/email.enum'; + +@Injectable() +export class EmailService { + constructor( + @InjectQueue(EmailConfig.EMAIL_QUEUE) private readonly emailQueue: Queue, + ) {} + + async sendEmail(data: EmailPayload) { + const { key, to, subject, options } = data; + const emailBody: EmailTransportPacket = { + to, + subject, + html: await this.compileTemplate(key, options), + }; + await this.emailQueue.add(key, emailBody); + } + + private async compileTemplate( + templateName: string, + payload: Record, + ): Promise { + const templatePath = this.getTemplatePath(templateName); + + try { + const templateSource = await fs.promises.readFile(templatePath, 'utf8'); + const template = handlebars.compile(templateSource); + return template(payload); + } catch (error) { + throw new Error( + `Failed to compile email template: ${templateName} error ${error}`, + ); + } + } + + private getTemplatePath(templateName: string): string { + const possiblePaths = [ + // For development + path.join( + __dirname, + '../../../src/modules/email/templates', + `${templateName}.hbs`, + ), + // For production (dist folder) + path.join(__dirname, '../email/templates', `${templateName}.hbs`), + ]; + for (const templatePath of possiblePaths) { + if (fs.existsSync(templatePath)) { + return templatePath; + } + } + + throw new Error( + `Template not found: ${templateName}. Searched in: ${possiblePaths.join(', ')}`, + ); + } +} diff --git a/src/modules/email/enums/email.enum.ts b/src/modules/email/enums/email.enum.ts new file mode 100644 index 0000000..5b33e02 --- /dev/null +++ b/src/modules/email/enums/email.enum.ts @@ -0,0 +1,7 @@ +export enum EmailConfig { + //config + EMAIL_QUEUE = 'emailQueue', + + //emails + SEND_LOGIN_ALERT_EMAIL = 'send-login-alert', +} diff --git a/src/modules/email/templates/send-login-alert.hbs b/src/modules/email/templates/send-login-alert.hbs new file mode 100644 index 0000000..0edfee1 --- /dev/null +++ b/src/modules/email/templates/send-login-alert.hbs @@ -0,0 +1,3 @@ +

Welcome, {{name}}!

+

You have successfully logged in at {{loginAt}}.

+

If this wasn't you, please contact support.

diff --git a/src/modules/email/types.d.ts b/src/modules/email/types.d.ts new file mode 100644 index 0000000..3ee4ed8 --- /dev/null +++ b/src/modules/email/types.d.ts @@ -0,0 +1,16 @@ +interface EmailPayload { + key: string; + from?: string; + to: string; + subject: string; + options?: Record; +} + +interface EmailTransportPacket { + from?: string; + to: string; + subject: string; + text?: string; + html?: string; + attachments?: Record[]; +} diff --git a/src/enums/permission.enum.ts b/src/modules/permission/enums/permission.enum.ts similarity index 100% rename from src/enums/permission.enum.ts rename to src/modules/permission/enums/permission.enum.ts diff --git a/src/enums/role.enum.ts b/src/modules/role/enums/role.enum.ts similarity index 100% rename from src/enums/role.enum.ts rename to src/modules/role/enums/role.enum.ts diff --git a/src/modules/role/role.controller.ts b/src/modules/role/role.controller.ts index bb8e704..d4c5d4a 100644 --- a/src/modules/role/role.controller.ts +++ b/src/modules/role/role.controller.ts @@ -11,12 +11,12 @@ import { } from '@nestjs/common'; import { Response } from 'express'; import { BaseController } from '../../common/controllers/base.controller'; +import { Permissions } from '../../common/decorators/permissions.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { PermissionGuard } from '../../common/guards/permission.guard'; -import { Permissions } from '../../common/decorators/permissions.decorator'; +import { PermissionName } from '../permission/enums/permission.enum'; import { CreateRoleDto } from './dto/role-create.dto'; import { RoleService } from './role.service'; -import { PermissionName } from '../../enums/permission.enum'; @Controller('role') export class RoleController extends BaseController { diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index dc10148..54c6c4d 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -20,7 +20,7 @@ import { Permissions } from '../../common/decorators/permissions.decorator'; import { ValidationExceptionFilter } from '../../common/filters/validation-exception.filter'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { PermissionGuard } from '../../common/guards/permission.guard'; -import { PermissionName } from '../../enums/permission.enum'; +import { PermissionName } from '../permission/enums/permission.enum'; import { ChangePasswordDto } from './dto/change-password.dto'; import { CreateUserDto } from './dto/create-user.dto'; import { PermissionAssignDto } from './dto/permission-assign.dto'; diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 8479d03..9e6a00d 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -45,10 +45,9 @@ export class UserService { createUserDto.password = await this.passwordService.hashPassword( createUserDto.password, ); - const roleIds = createUserDto.roles.filter( + createUserDto.roles = createUserDto.roles.filter( (roleId) => !this.UNASSIGNABLE_ROLE_IDS.includes(roleId), ); - createUserDto.roles = roleIds; const user = this.userRepository.create(createUserDto); await this.em.persistAndFlush(user); await this.cacheService.delAll(`${this.USER_PAGINATED_CACHE_PREFIX}*`); @@ -112,8 +111,7 @@ export class UserService { } async findByEmail(email: string): Promise | null> { - const user = await this.userRepository.findOne({ email }); - return user; // not need to call instanceToPlain as we need password in response + return await this.userRepository.findOne({ email }); } async findByEmailWithRole( @@ -175,7 +173,7 @@ export class UserService { delete updateUserDto.roles; this.userRepository.assign(user, updateUserDto); await this.em.flush(); - this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); + await this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); return this.userTransformer.transform(user); } @@ -186,7 +184,7 @@ export class UserService { } this.userRepository.assign(user, { lastLoginAt: new Date() }); await this.em.flush(); - this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); + await this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); await this.cacheService.delAll(`${this.USER_PAGINATED_CACHE_PREFIX}*`); return this.userTransformer.transform(user); } @@ -204,7 +202,7 @@ export class UserService { this.userRepository.assign(user, restOfDto); await this.em.flush(); - this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); + await this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); return this.userTransformer.transform(user); } @@ -251,7 +249,7 @@ export class UserService { throw new NotFoundException('User not found'); } try { - this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); + await this.cacheService.del(`${this.USER_CACHE_PREFIX}${id}`); await this.cacheService.delAll(`${this.USER_PAGINATED_CACHE_PREFIX}*`); await this.em.removeAndFlush(user); return true; @@ -283,7 +281,7 @@ export class UserService { user.roles.set(roles); await this.em.flush(); - this.cacheService.del(`${this.USER_CACHE_PREFIX}${userId}`); + await this.cacheService.del(`${this.USER_CACHE_PREFIX}${userId}`); return this.userTransformer.transform(user); } @@ -307,7 +305,7 @@ export class UserService { user.permissions.set(permissions); await this.em.flush(); - this.cacheService.del(`${this.USER_CACHE_PREFIX}${userId}`); + await this.cacheService.del(`${this.USER_CACHE_PREFIX}${userId}`); return this.userTransformer.transform(user); }