├── .dockerignore ├── .env.docker ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── ci └── Dockerfile ├── nest-cli.json ├── package.json ├── prisma ├── migrations │ ├── 20241031144907_initial │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── app │ ├── app.controller.ts │ └── app.module.ts ├── common │ ├── common.module.ts │ ├── config │ │ ├── app.config.ts │ │ ├── auth.config.ts │ │ ├── doc.config.ts │ │ ├── index.ts │ │ └── rmq.config.ts │ ├── constants │ │ └── app.constant.ts │ ├── decorators │ │ ├── auth.decorator.ts │ │ ├── payload.decorator.ts │ │ ├── public.decorator.ts │ │ └── role.decorator.ts │ ├── filters │ │ └── exception.filter.ts │ ├── guards │ │ ├── jwt.access.guard.ts │ │ ├── jwt.refresh.guard.ts │ │ └── roles.guard.ts │ ├── interceptors │ │ └── response.interceptor.ts │ ├── middlewares │ │ └── logging.middleware.ts │ ├── providers │ │ ├── jwt.access.strategy.ts │ │ └── jwt.refresh.strategy.ts │ └── services │ │ ├── hash.service.ts │ │ └── prisma.service.ts ├── languages │ └── en │ │ ├── auth.json │ │ ├── http.json │ │ └── user.json ├── main.ts ├── modules │ ├── auth │ │ ├── auth.module.ts │ │ ├── controllers │ │ │ └── auth.public.controller.ts │ │ ├── dtos │ │ │ ├── auth.login.dto.ts │ │ │ ├── auth.response.dto.ts │ │ │ └── auth.signup.dto.ts │ │ ├── interfaces │ │ │ ├── auth.interface.ts │ │ │ └── auth.service.interface.ts │ │ └── services │ │ │ └── auth.service.ts │ └── user │ │ ├── controllers │ │ ├── user.admin.controller.ts │ │ └── user.auth.controller.ts │ │ ├── dtos │ │ ├── user.response.dto.ts │ │ └── user.update.dto.ts │ │ ├── interfaces │ │ └── user.service.interface.ts │ │ ├── services │ │ └── user.service.ts │ │ └── user.module.ts └── swagger.ts ├── test ├── auth.service.spec.ts ├── hash.service.spec.ts ├── jest.json └── user.service.spec.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.env.docker: -------------------------------------------------------------------------------- 1 | APP_NAME="@backendworks/auth" 2 | APP_ENV="development" 3 | 4 | HTTP_ENABLE=true 5 | HTTP_HOST="0.0.0.0" 6 | HTTP_PORT=9001 7 | HTTP_VERSIONING_ENABLE=true 8 | HTTP_VERSION=1 9 | 10 | DATABASE_URL="postgresql://admin:master123@postgres:5432/auth_db?schema=public" 11 | 12 | ACCESS_TOKEN_SECRET_KEY="testme" 13 | ACCESS_TOKEN_EXPIRED="1d" 14 | REFRESH_TOKEN_SECRET_KEY="testme" 15 | REFRESH_TOKEN_EXPIRED="7d" 16 | 17 | RABBITMQ_URL="amqp://admin:master123@rabbitmq:5672" 18 | RABBITMQ_AUTH_QUEUE="auth_queue" 19 | 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | .github 3 | .husky 4 | node_modules 5 | coverage -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | settings: { 3 | 'import/parsers': { 4 | '@typescript-eslint/parser': ['.ts', '.tsx', '.json'], 5 | }, 6 | }, 7 | parserOptions: { 8 | project: 'tsconfig.json', 9 | tsconfigRootDir: __dirname, 10 | sourceType: 'module', 11 | }, 12 | plugins: [ 13 | '@typescript-eslint/eslint-plugin', 14 | 'prettier', 15 | 'no-relative-import-paths', 16 | ], 17 | extends: [ 18 | 'plugin:@typescript-eslint/eslint-recommended', 19 | 'plugin:@typescript-eslint/recommended', 20 | 'prettier', 21 | ], 22 | root: true, 23 | env: { 24 | node: true, 25 | jest: true, 26 | }, 27 | ignorePatterns: ['.eslintrc.js', '*.json'], 28 | rules: { 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | 'no-unused-vars': 'off', 31 | '@typescript-eslint/no-unused-vars': [ 32 | 'warn', 33 | { 34 | args: 'all', 35 | argsIgnorePattern: '^_', 36 | caughtErrors: 'all', 37 | caughtErrorsIgnorePattern: '^_', 38 | destructuredArrayIgnorePattern: '^_', 39 | varsIgnorePattern: '^_', 40 | ignoreRestSiblings: true, 41 | }, 42 | ], 43 | '@typescript-eslint/await-thenable': 'error', 44 | // Configure no-relative-import-paths rule 45 | 'no-relative-import-paths/no-relative-import-paths': [ 46 | 'warn', 47 | { 48 | allowSameFolder: true, 49 | rootDir: 'src', 50 | }, 51 | ], 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | .env 37 | 38 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | yarn test 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": true, 5 | "printWidth": 100, 6 | "tabWidth": 4, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "arrowParens": "avoid", 11 | "endOfLine": "lf" 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "always", 6 | "source.organizeImports": "always" 7 | }, 8 | "[typescript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode", 10 | "editor.formatOnSave": true 11 | }, 12 | 13 | "[javascript]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode", 15 | "editor.formatOnSave": true 16 | }, 17 | 18 | "[json]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode", 20 | "editor.formatOnSave": true 21 | }, 22 | 23 | "[prisma]": { 24 | "editor.defaultFormatter": "Prisma.prisma", 25 | "editor.formatOnSave": true 26 | }, 27 | 28 | "typescript.preferences.importModuleSpecifier": "relative", 29 | "javascript.preferences.importModuleSpecifier": "relative", 30 | 31 | "eslint.validate": ["javascript", "typescript"] 32 | } 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock ./ 6 | COPY prisma ./prisma 7 | 8 | RUN yarn install --frozen-lockfile 9 | 10 | COPY . . 11 | 12 | EXPOSE 9001 13 | 14 | CMD [ "yarn", "dev" ] 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | [Nest](https://github.com/nestjs/nest) Microservice framework auth service TypeScript repository. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | $ yarn 9 | ``` 10 | 11 | ## Running the app 12 | 13 | ```bash 14 | # development 15 | $ yarn dev 16 | 17 | # production mode 18 | $ yarn start 19 | ``` 20 | 21 | ## Database 22 | 23 | ```bash 24 | # generate schema 25 | $ yarn generate 26 | 27 | # migrate dev 28 | $ yarn migrate 29 | 30 | # migrate prod 31 | $ yarn migrate:prod 32 | ``` 33 | 34 | ## Test 35 | 36 | ```bash 37 | # unit tests 38 | $ yarn test 39 | 40 | # e2e tests 41 | $ yarn test:e2e 42 | 43 | # test coverage 44 | $ yarn test:cov 45 | ``` 46 | 47 | ## License 48 | 49 | Nest is [MIT licensed](LICENSE). 50 | -------------------------------------------------------------------------------- /ci/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock ./ 6 | COPY prisma ./prisma 7 | 8 | RUN yarn install --frozen-lockfile 9 | 10 | COPY . . 11 | 12 | RUN yarn generate 13 | 14 | RUN yarn build 15 | 16 | FROM node:20-alpine AS production 17 | 18 | WORKDIR /app 19 | 20 | COPY --from=builder /app/package.json /app/yarn.lock /app/ 21 | COPY --from=builder /app/dist /app/dist 22 | COPY --from=builder /app/prisma /app/prisma 23 | 24 | RUN yarn install --production --frozen-lockfile 25 | 26 | EXPOSE 9001 27 | 28 | CMD [ "yarn", "start" ] 29 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "plugins": ["@nestjs/swagger"], 6 | "assets": [ 7 | { 8 | "include": "languages/**/*", 9 | "outDir": "dist", 10 | "watchAssets": true 11 | } 12 | ], 13 | "deleteOutDir": true, 14 | "watchAssets": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@backendworks/auth", 3 | "version": "2.0.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0", 7 | "npm": ">=9.0.0" 8 | }, 9 | "scripts": { 10 | "prebuild": "rimraf dist", 11 | "build": "nest build", 12 | "build:prod": "nest build", 13 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 14 | "start": "node dist/main", 15 | "dev": "nest start --watch", 16 | "lint": "eslint \"{src,test}/**/*.ts\" --fix", 17 | "test": "jest --config test/jest.json --runInBand", 18 | "prisma:generate": "prisma generate", 19 | "prisma:migrate": "prisma migrate dev", 20 | "prisma:migrate:prod": "prisma migrate deploy", 21 | "prisma:studio": "prisma studio", 22 | "prepare": "husky" 23 | }, 24 | "dependencies": { 25 | "@faker-js/faker": "^9.1.0", 26 | "@nestjs/common": "^10.4.6", 27 | "@nestjs/config": "^3.3.0", 28 | "@nestjs/core": "^10.4.6", 29 | "@nestjs/jwt": "^10.2.0", 30 | "@nestjs/microservices": "^10.4.6", 31 | "@nestjs/passport": "^10.0.3", 32 | "@nestjs/platform-express": "^10.4.6", 33 | "@nestjs/swagger": "^8.0.1", 34 | "@nestjs/terminus": "^10.2.3", 35 | "@prisma/client": "^5.21.1", 36 | "amqp-connection-manager": "^4.1.14", 37 | "amqplib": "^0.10.4", 38 | "bcrypt": "^5.1.1", 39 | "class-transformer": "^0.5.1", 40 | "class-validator": "^0.14.1", 41 | "dotenv": "^16.4.5", 42 | "express": "^4.21.1", 43 | "helmet": "^8.0.0", 44 | "nestjs-i18n": "^10.4.9", 45 | "passport": "^0.7.0", 46 | "passport-jwt": "^4.0.1", 47 | "reflect-metadata": "^0.2.2", 48 | "rxjs": "^7.8.1" 49 | }, 50 | "devDependencies": { 51 | "@nestjs/cli": "^10.4.5", 52 | "@nestjs/schematics": "^10.2.3", 53 | "@nestjs/testing": "^10.4.6", 54 | "@types/amqplib": "^0.10.5", 55 | "@types/bcrypt": "^5.0.2", 56 | "@types/config": "^3.3.5", 57 | "@types/express": "^5.0.0", 58 | "@types/jest": "^29.5.14", 59 | "@types/ms": "^0.7.34", 60 | "@types/node": "^22.8.5", 61 | "@types/passport-jwt": "^4.0.1", 62 | "@types/pg": "^8.11.10", 63 | "@types/supertest": "^6.0.2", 64 | "@typescript-eslint/eslint-plugin": "^8.12.2", 65 | "@typescript-eslint/parser": "^8.12.2", 66 | "eslint": "^8.13.0", 67 | "eslint-config-prettier": "^9.1.0", 68 | "eslint-plugin-import": "^2.31.0", 69 | "eslint-plugin-no-relative-import-paths": "^1.5.5", 70 | "eslint-plugin-prettier": "^5.2.1", 71 | "husky": "^9.1.6", 72 | "jest": "^29.7.0", 73 | "lint-staged": "^15.2.10", 74 | "prettier": "^3.3.3", 75 | "prisma": "^5.21.1", 76 | "source-map-support": "^0.5.21", 77 | "supertest": "^7.0.0", 78 | "ts-jest": "^29.2.5", 79 | "ts-loader": "^9.5.1", 80 | "ts-node": "^10.9.2", 81 | "tsconfig-paths": "^4.2.0", 82 | "typescript": "^5.6.3" 83 | }, 84 | "lint-staged": { 85 | "*.ts": [ 86 | "prettier --write", 87 | "eslint --fix" 88 | ] 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /prisma/migrations/20241031144907_initial/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "users" ( 6 | "id" TEXT NOT NULL, 7 | "email" TEXT NOT NULL, 8 | "password" TEXT, 9 | "first_name" TEXT, 10 | "last_name" TEXT, 11 | "avatar" TEXT, 12 | "is_verified" BOOLEAN NOT NULL DEFAULT false, 13 | "phone_number" TEXT, 14 | "role" "Role" NOT NULL, 15 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | "updated_at" TIMESTAMP(3) NOT NULL, 17 | "deleted_at" TIMESTAMP(3), 18 | 19 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateIndex 23 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); 24 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(uuid()) @map("id") 15 | email String @unique @map("email") 16 | password String? @map("password") 17 | firstName String? @map("first_name") 18 | lastName String? @map("last_name") 19 | avatar String? @map("avatar") 20 | isVerified Boolean @default(false) @map("is_verified") 21 | phoneNumber String? @map("phone_number") 22 | role Role 23 | createdAt DateTime @default(now()) @map("created_at") 24 | updatedAt DateTime @updatedAt @map("updated_at") 25 | deletedAt DateTime? @map("deleted_at") 26 | 27 | @@map("users") 28 | } 29 | 30 | enum Role { 31 | ADMIN @map("ADMIN") 32 | USER @map("USER") 33 | } 34 | -------------------------------------------------------------------------------- /src/app/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common'; 2 | import { MessagePattern } from '@nestjs/microservices'; 3 | import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; 4 | 5 | import { TransformMessagePayload } from 'src/common/decorators/payload.decorator'; 6 | import { Public } from 'src/common/decorators/public.decorator'; 7 | import { PrismaService } from 'src/common/services/prisma.service'; 8 | import { AuthService } from 'src/modules/auth/services/auth.service'; 9 | import { UserService } from 'src/modules/user/services/user.service'; 10 | 11 | @Controller({ 12 | version: VERSION_NEUTRAL, 13 | path: '/', 14 | }) 15 | export class AppController { 16 | constructor( 17 | private readonly healthCheckService: HealthCheckService, 18 | private readonly prismaService: PrismaService, 19 | private readonly authService: AuthService, 20 | private readonly userService: UserService, 21 | ) {} 22 | 23 | @Get('/health') 24 | @HealthCheck() 25 | @Public() 26 | public async getHealth() { 27 | return this.healthCheckService.check([() => this.prismaService.isHealthy()]); 28 | } 29 | 30 | @MessagePattern('validateToken') 31 | public async getUserByAccessToken(@TransformMessagePayload() payload: Record) { 32 | return this.authService.verifyToken(payload.token); 33 | } 34 | 35 | @MessagePattern('getUserById') 36 | public async getUserById(@TransformMessagePayload() payload: Record) { 37 | return this.userService.getUserById(payload.userId); 38 | } 39 | 40 | @MessagePattern('getUserByEmail') 41 | public async getUserByEmail(@TransformMessagePayload() payload: Record) { 42 | return this.userService.getUserByEmail(payload.userName); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TerminusModule } from '@nestjs/terminus'; 3 | 4 | import { CommonModule } from 'src/common/common.module'; 5 | import { AuthModule } from 'src/modules/auth/auth.module'; 6 | import { UserModule } from 'src/modules/user/user.module'; 7 | 8 | import { AppController } from './app.controller'; 9 | 10 | @Module({ 11 | imports: [TerminusModule, CommonModule, UserModule, AuthModule], 12 | controllers: [AppController], 13 | }) 14 | export class AppModule {} 15 | -------------------------------------------------------------------------------- /src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; 6 | import { PassportModule } from '@nestjs/passport'; 7 | import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n'; 8 | 9 | import configs from './config'; 10 | import { HttpExceptionFilter } from './filters/exception.filter'; 11 | import { AuthJwtAccessGuard } from './guards/jwt.access.guard'; 12 | import { RolesGuard } from './guards/roles.guard'; 13 | import { ResponseInterceptor } from './interceptors/response.interceptor'; 14 | import { LoggingMiddleware } from './middlewares/logging.middleware'; 15 | import { AuthJwtAccessStrategy } from './providers/jwt.access.strategy'; 16 | import { AuthJwtRefreshStrategy } from './providers/jwt.refresh.strategy'; 17 | import { HashService } from './services/hash.service'; 18 | import { PrismaService } from './services/prisma.service'; 19 | 20 | @Module({ 21 | imports: [ 22 | ConfigModule.forRoot({ 23 | load: configs, 24 | isGlobal: true, 25 | cache: true, 26 | envFilePath: ['.env'], 27 | expandVariables: true, 28 | }), 29 | PassportModule.register({ defaultStrategy: 'jwt' }), 30 | I18nModule.forRoot({ 31 | fallbackLanguage: 'en', 32 | loaderOptions: { 33 | path: join(__dirname, '../languages/'), 34 | watch: true, 35 | }, 36 | resolvers: [{ use: QueryResolver, options: ['lang'] }, AcceptLanguageResolver], 37 | }), 38 | ], 39 | providers: [ 40 | PrismaService, 41 | HashService, 42 | AuthJwtAccessStrategy, 43 | AuthJwtRefreshStrategy, 44 | { 45 | provide: APP_INTERCEPTOR, 46 | useClass: ResponseInterceptor, 47 | }, 48 | { 49 | provide: APP_FILTER, 50 | useClass: HttpExceptionFilter, 51 | }, 52 | { 53 | provide: APP_GUARD, 54 | useClass: AuthJwtAccessGuard, 55 | }, 56 | { 57 | provide: APP_GUARD, 58 | useClass: RolesGuard, 59 | }, 60 | ], 61 | exports: [PrismaService, HashService, AuthJwtAccessStrategy, AuthJwtRefreshStrategy], 62 | }) 63 | export class CommonModule implements NestModule { 64 | configure(consumer: MiddlewareConsumer): void { 65 | consumer.apply(LoggingMiddleware).forRoutes('*'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/common/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs( 4 | 'app', 5 | (): Record => ({ 6 | name: process.env.APP_NAME ?? 'auth', 7 | env: process.env.APP_ENV ?? 'development', 8 | versioning: { 9 | enable: process.env.HTTP_VERSIONING_ENABLE === 'true', 10 | prefix: 'v', 11 | version: process.env.HTTP_VERSION ?? '1', 12 | }, 13 | globalPrefix: '/api', 14 | http: { 15 | enable: process.env.HTTP_ENABLE === 'true', 16 | host: process.env.HTTP_HOST ?? '0.0.0.0', 17 | port: process.env.HTTP_PORT ? Number.parseInt(process.env.HTTP_PORT) : 9001, 18 | }, 19 | }), 20 | ); 21 | -------------------------------------------------------------------------------- /src/common/config/auth.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import ms from 'ms'; 3 | 4 | function seconds(msValue: string): number { 5 | return ms(msValue) / 1000; 6 | } 7 | 8 | export default registerAs( 9 | 'auth', 10 | (): Record => ({ 11 | accessToken: { 12 | secret: process.env.ACCESS_TOKEN_SECRET_KEY, 13 | expirationTime: seconds(process.env.ACCESS_TOKEN_EXPIRED ?? '1d'), 14 | }, 15 | refreshToken: { 16 | secret: process.env.REFRESH_TOKEN_SECRET_KEY, 17 | expirationTime: seconds(process.env.REFRESH_TOKEN_EXPIRED ?? '7d'), 18 | }, 19 | }), 20 | ); 21 | -------------------------------------------------------------------------------- /src/common/config/doc.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs( 4 | 'doc', 5 | (): Record => ({ 6 | name: `${process.env.APP_NAME} APIs Specification`, 7 | description: `${process.env.APP_NAME} APIs Description`, 8 | version: '1.0', 9 | prefix: '/docs', 10 | }), 11 | ); 12 | -------------------------------------------------------------------------------- /src/common/config/index.ts: -------------------------------------------------------------------------------- 1 | import AppConfig from './app.config'; 2 | import AuthConfig from './auth.config'; 3 | import DocConfig from './doc.config'; 4 | import RmqConfig from './rmq.config'; 5 | 6 | export default [AppConfig, AuthConfig, DocConfig, RmqConfig]; 7 | -------------------------------------------------------------------------------- /src/common/config/rmq.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs( 4 | 'rmq', 5 | (): Record => ({ 6 | uri: process.env.RABBITMQ_URL, 7 | auth: process.env.RABBITMQ_AUTH_QUEUE, 8 | }), 9 | ); 10 | -------------------------------------------------------------------------------- /src/common/constants/app.constant.ts: -------------------------------------------------------------------------------- 1 | export const PUBLIC_ROUTE_KEY = 'Public'; 2 | -------------------------------------------------------------------------------- /src/common/decorators/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const AuthUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { 4 | const request = ctx.switchToHttp().getRequest(); 5 | return request.user; 6 | }); 7 | -------------------------------------------------------------------------------- /src/common/decorators/payload.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const TransformMessagePayload = createParamDecorator( 4 | (_data: unknown, ctx: ExecutionContext) => { 5 | const message = ctx.switchToRpc().getData(); 6 | try { 7 | return JSON.parse(message); 8 | } catch (error) { 9 | throw error; 10 | } 11 | }, 12 | ); 13 | -------------------------------------------------------------------------------- /src/common/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { CustomDecorator, SetMetadata } from '@nestjs/common'; 2 | 3 | import { PUBLIC_ROUTE_KEY } from 'src/common/constants/app.constant'; 4 | 5 | export const Public = (): CustomDecorator => SetMetadata(PUBLIC_ROUTE_KEY, true); 6 | -------------------------------------------------------------------------------- /src/common/decorators/role.decorator.ts: -------------------------------------------------------------------------------- 1 | import { CustomDecorator, SetMetadata } from '@nestjs/common'; 2 | 3 | export const AllowedRoles = (roles: string[]): CustomDecorator => 4 | SetMetadata('roles', roles); 5 | -------------------------------------------------------------------------------- /src/common/filters/exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | HttpStatus, 7 | Logger, 8 | } from '@nestjs/common'; 9 | import { Response } from 'express'; 10 | import { I18nService } from 'nestjs-i18n'; 11 | 12 | @Catch() 13 | export class HttpExceptionFilter implements ExceptionFilter { 14 | private readonly logger = new Logger(HttpExceptionFilter.name); 15 | 16 | constructor(private readonly i18n: I18nService) {} 17 | 18 | async catch(exception: unknown, host: ArgumentsHost) { 19 | const context = host.switchToHttp(); 20 | const response = context.getResponse(); 21 | 22 | const statusCode = 23 | exception instanceof HttpException 24 | ? exception.getStatus() 25 | : HttpStatus.INTERNAL_SERVER_ERROR; 26 | 27 | const translationKey = 28 | exception instanceof HttpException && exception.message 29 | ? exception.message 30 | : `http.${HttpStatus.INTERNAL_SERVER_ERROR}`; 31 | 32 | const message = await this.i18n.t(translationKey); 33 | 34 | const errorResponse = { 35 | statusCode, 36 | timestamp: new Date().toISOString(), 37 | message, 38 | }; 39 | 40 | if (statusCode === HttpStatus.INTERNAL_SERVER_ERROR) { 41 | const errorDetails = { 42 | ...errorResponse, 43 | stack: exception instanceof Error ? exception.stack : undefined, 44 | }; 45 | this.logger.error(JSON.stringify(errorDetails)); 46 | } 47 | 48 | response.status(statusCode).json(errorResponse); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/common/guards/jwt.access.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | 5 | import { PUBLIC_ROUTE_KEY } from 'src/common/constants/app.constant'; 6 | 7 | @Injectable() 8 | export class AuthJwtAccessGuard extends AuthGuard('jwt-access') { 9 | constructor(private readonly reflector: Reflector) { 10 | super(); 11 | } 12 | 13 | handleRequest( 14 | err: Error, 15 | user: TUser, 16 | _info: Error, 17 | context, 18 | ): TUser { 19 | const isRpc = context.getType() === 'rpc'; 20 | const isPublic = this.reflector.getAllAndOverride(PUBLIC_ROUTE_KEY, [ 21 | context.getHandler(), 22 | context.getClass(), 23 | ]); 24 | 25 | if (isPublic || isRpc) { 26 | return; 27 | } 28 | 29 | if (err || !user) { 30 | throw new UnauthorizedException('auth.accessTokenUnauthorized'); 31 | } 32 | return user; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/common/guards/jwt.refresh.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class AuthJwtRefreshGuard extends AuthGuard('jwt-refresh') { 6 | constructor() { 7 | super(); 8 | } 9 | 10 | handleRequest( 11 | err: Error, 12 | user: TUser, 13 | _info: Error, 14 | context, 15 | ): TUser { 16 | const isRpc = context.getType() === 'rpc'; 17 | if (isRpc) { 18 | return; 19 | } 20 | 21 | if (err || !user) { 22 | throw new UnauthorizedException('auth.refreshTokenUnauthorized'); 23 | } 24 | return user; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/common/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | 4 | import { AllowedRoles } from 'src/common/decorators/role.decorator'; 5 | 6 | @Injectable() 7 | export class RolesGuard implements CanActivate { 8 | constructor(private reflector: Reflector) {} 9 | 10 | canActivate(context: ExecutionContext): boolean { 11 | const roles = this.reflector.get(AllowedRoles, context.getHandler()); 12 | if (!roles) { 13 | return true; 14 | } 15 | const request = context.switchToHttp().getRequest(); 16 | const user = request.user; 17 | return roles.includes(user.role); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/common/interceptors/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; 2 | import { HttpArgumentsHost } from '@nestjs/common/interfaces'; 3 | import { I18nService } from 'nestjs-i18n'; 4 | import { Observable, firstValueFrom, of } from 'rxjs'; 5 | 6 | @Injectable() 7 | export class ResponseInterceptor implements NestInterceptor { 8 | constructor(private readonly i18n: I18nService) {} 9 | 10 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 11 | const ctx: HttpArgumentsHost = context.switchToHttp(); 12 | const response = ctx.getResponse(); 13 | const statusCode: number = response.statusCode; 14 | const responseBody = await firstValueFrom(next.handle()); 15 | const message = this.i18n.t(`http.${statusCode}`); 16 | 17 | return of({ 18 | statusCode, 19 | timestamp: new Date().toISOString(), 20 | message, 21 | data: responseBody, 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/common/middlewares/logging.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | 4 | @Injectable() 5 | export class LoggingMiddleware implements NestMiddleware { 6 | private logger = new Logger('HTTP'); 7 | 8 | use(req: Request, res: Response, next: NextFunction): void { 9 | const { method, originalUrl } = req; 10 | const startTime = Date.now(); 11 | 12 | res.on('finish', () => { 13 | const { statusCode } = res; 14 | const contentLength = res.get('content-length'); 15 | const responseTime = Date.now() - startTime; 16 | const logMessage = `${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${responseTime}ms`; 17 | this.logger.log(logMessage); 18 | }); 19 | 20 | next(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/common/providers/jwt.access.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | 6 | import { IAuthPayload } from 'src/modules/auth/interfaces/auth.interface'; 7 | 8 | @Injectable() 9 | export class AuthJwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') { 10 | constructor(private readonly configService: ConfigService) { 11 | super({ 12 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 13 | ignoreExpiration: false, 14 | secretOrKey: configService.get('auth.accessToken.secret'), 15 | }); 16 | } 17 | 18 | async validate(payload: IAuthPayload) { 19 | return payload; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/common/providers/jwt.refresh.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | 6 | import { IAuthPayload } from 'src/modules/auth/interfaces/auth.interface'; 7 | 8 | @Injectable() 9 | export class AuthJwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { 10 | constructor(private readonly configService: ConfigService) { 11 | super({ 12 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 13 | ignoreExpiration: false, 14 | secretOrKey: configService.get('auth.refreshToken.secret'), 15 | }); 16 | } 17 | 18 | async validate(payload: IAuthPayload) { 19 | return payload; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/common/services/hash.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import * as bcrypt from 'bcrypt'; 3 | 4 | @Injectable() 5 | export class HashService { 6 | private readonly salt: string; 7 | 8 | constructor() { 9 | this.salt = bcrypt.genSaltSync(); 10 | } 11 | 12 | public createHash(password: string): string { 13 | return bcrypt.hashSync(password, this.salt); 14 | } 15 | 16 | public match(hash: string, password: string): boolean { 17 | return bcrypt.compareSync(password, hash); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/common/services/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { HealthIndicatorResult } from '@nestjs/terminus'; 3 | import { PrismaClient } from '@prisma/client'; 4 | 5 | @Injectable() 6 | export class PrismaService extends PrismaClient implements OnModuleInit { 7 | async onModuleInit() { 8 | await this.$connect(); 9 | } 10 | 11 | async isHealthy(): Promise { 12 | try { 13 | await this.$queryRaw`SELECT 1`; 14 | return Promise.resolve({ 15 | prisma: { 16 | status: 'up', 17 | }, 18 | }); 19 | } catch (error) { 20 | console.error(error); 21 | return Promise.resolve({ 22 | prisma: { 23 | status: 'down', 24 | }, 25 | }); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/languages/en/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "refreshTokenUnauthorized": "Refresh token is unauthorized.", 3 | "accessTokenUnauthorized": "Access token is unauthorized." 4 | } 5 | -------------------------------------------------------------------------------- /src/languages/en/http.json: -------------------------------------------------------------------------------- 1 | { 2 | "200": "OK", 3 | "201": "Created", 4 | "202": "Accepted", 5 | "203": "Non Authoritative Info", 6 | "204": "No Content", 7 | "205": "Reset Content", 8 | "206": "Partial Content", 9 | "400": "Bad Request", 10 | "401": "Unauthorized", 11 | "403": "Forbidden", 12 | "404": "Not Found", 13 | "405": "Method Not Allowed", 14 | "409": "Conflict", 15 | "422": "Unprocessable Entity", 16 | "429": "Too Many Requests", 17 | "500": "Internal Server Error" 18 | } 19 | -------------------------------------------------------------------------------- /src/languages/en/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "userExistsByEmail": "An account with this email already exists.", 3 | "userExistsByUserName": "An account with this username already exists.", 4 | "userNotFound": "User not found.", 5 | "invalidPassword": "The provided password is invalid.", 6 | "userDeleted": "User has been successfully deleted." 7 | } 8 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { NestFactory } from '@nestjs/core'; 4 | import { Transport } from '@nestjs/microservices'; 5 | import { ExpressAdapter } from '@nestjs/platform-express'; 6 | import express, { Request, Response } from 'express'; 7 | import helmet from 'helmet'; 8 | 9 | import { AppModule } from './app/app.module'; 10 | import { setupSwagger } from './swagger'; 11 | 12 | async function bootstrap() { 13 | const logger = new Logger(); 14 | const app = await NestFactory.create(AppModule, new ExpressAdapter(express()), { 15 | cors: true, 16 | }); 17 | 18 | const configService = app.get(ConfigService); 19 | const expressApp = app.getHttpAdapter().getInstance(); 20 | 21 | expressApp.get('/', (_req: Request, res: Response) => { 22 | res.status(200).json({ 23 | status: 200, 24 | message: `Hello from ${configService.get('app.name')}`, 25 | timestamp: new Date().toISOString(), 26 | }); 27 | }); 28 | 29 | const port: number = configService.get('app.http.port'); 30 | const host: string = configService.get('app.http.host'); 31 | const globalPrefix: string = configService.get('app.globalPrefix'); 32 | const versioningPrefix: string = configService.get('app.versioning.prefix'); 33 | const version: string = configService.get('app.versioning.version'); 34 | const versionEnable: string = configService.get('app.versioning.enable'); 35 | app.use(helmet()); 36 | app.useGlobalPipes(new ValidationPipe()); 37 | app.setGlobalPrefix(globalPrefix); 38 | if (versionEnable) { 39 | app.enableVersioning({ 40 | type: VersioningType.URI, 41 | defaultVersion: version, 42 | prefix: versioningPrefix, 43 | }); 44 | } 45 | setupSwagger(app); 46 | app.connectMicroservice({ 47 | transport: Transport.RMQ, 48 | options: { 49 | urls: [`${configService.get('rmq.uri')}`], 50 | queue: `${configService.get('rmq.auth')}`, 51 | queueOptions: { durable: false }, 52 | prefetchCount: 1, 53 | }, 54 | }); 55 | await app.startAllMicroservices(); 56 | await app.listen(port, host); 57 | logger.log(`🚀 ${configService.get('app.name')} service started successfully on port ${port}`); 58 | } 59 | bootstrap(); 60 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | 5 | import { CommonModule } from 'src/common/common.module'; 6 | import { UserService } from 'src/modules/user/services/user.service'; 7 | 8 | import { PublicAuthController } from './controllers/auth.public.controller'; 9 | import { AuthService } from './services/auth.service'; 10 | 11 | @Module({ 12 | imports: [ 13 | CommonModule, 14 | PassportModule.register({ 15 | session: false, 16 | }), 17 | JwtModule.register({}), 18 | ], 19 | controllers: [PublicAuthController], 20 | providers: [AuthService, UserService], 21 | exports: [AuthService], 22 | }) 23 | export class AuthModule {} 24 | -------------------------------------------------------------------------------- /src/modules/auth/controllers/auth.public.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | 4 | import { AuthUser } from 'src/common/decorators/auth.decorator'; 5 | import { Public } from 'src/common/decorators/public.decorator'; 6 | 7 | import { AuthJwtRefreshGuard } from 'src/common/guards/jwt.refresh.guard'; 8 | import { AuthLoginDto } from 'src/modules/auth/dtos/auth.login.dto'; 9 | import { AuthRefreshResponseDto, AuthResponseDto } from 'src/modules/auth/dtos/auth.response.dto'; 10 | import { AuthSignupDto } from 'src/modules/auth/dtos/auth.signup.dto'; 11 | import { IAuthPayload } from 'src/modules/auth/interfaces/auth.interface'; 12 | import { AuthService } from 'src/modules/auth/services/auth.service'; 13 | 14 | @ApiTags('public.auth') 15 | @Controller({ 16 | version: '1', 17 | path: '/auth', 18 | }) 19 | export class PublicAuthController { 20 | constructor(private readonly authService: AuthService) {} 21 | 22 | @Public() 23 | @Post('login') 24 | public login(@Body() payload: AuthLoginDto): Promise { 25 | return this.authService.login(payload); 26 | } 27 | 28 | @Public() 29 | @Post('signup') 30 | public signup(@Body() payload: AuthSignupDto): Promise { 31 | return this.authService.signup(payload); 32 | } 33 | 34 | @Public() 35 | @UseGuards(AuthJwtRefreshGuard) 36 | @Get('refresh') 37 | public refreshTokens(@AuthUser() user: IAuthPayload): Promise { 38 | return this.authService.generateTokens(user); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/auth/dtos/auth.login.dto.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; 4 | 5 | export class AuthLoginDto { 6 | @ApiProperty({ 7 | example: faker.internet.email(), 8 | description: 'The email address of the user', 9 | }) 10 | @IsEmail() 11 | @IsString() 12 | @IsNotEmpty() 13 | public email: string; 14 | 15 | @ApiProperty({ 16 | example: faker.internet.password(), 17 | description: 'The password of the user', 18 | }) 19 | @IsString() 20 | @IsNotEmpty() 21 | @MinLength(8) 22 | public password: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/auth/dtos/auth.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { ValidateNested } from 'class-validator'; 4 | 5 | import { UserResponseDto } from 'src/modules/user/dtos/user.response.dto'; 6 | 7 | export class AuthRefreshResponseDto { 8 | @ApiProperty({ 9 | description: 'The access token for the authenticated user', 10 | example: 11 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', 12 | }) 13 | accessToken: string; 14 | 15 | @ApiProperty({ 16 | description: 'The refresh token for obtaining a new access token', 17 | example: 18 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.J2Hk_UHzVcgKeNqw2c8LjRnRX7JouiBGmmuVjHAi0IQ', 19 | }) 20 | refreshToken: string; 21 | } 22 | 23 | export class AuthResponseDto extends AuthRefreshResponseDto { 24 | @ApiProperty({ 25 | description: 'The user details', 26 | type: UserResponseDto, 27 | }) 28 | @Type(() => UserResponseDto) 29 | @ValidateNested() 30 | user: UserResponseDto; 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/auth/dtos/auth.signup.dto.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; 4 | 5 | export class AuthSignupDto { 6 | @ApiProperty({ 7 | example: faker.internet.email(), 8 | description: 'The email address of the user', 9 | }) 10 | @IsEmail() 11 | @IsString() 12 | @IsNotEmpty() 13 | public email: string; 14 | 15 | @ApiProperty({ 16 | example: faker.internet.password(), 17 | description: 'The password of the user', 18 | }) 19 | @IsString() 20 | @IsNotEmpty() 21 | @MinLength(8) 22 | public password: string; 23 | 24 | @ApiProperty({ 25 | example: faker.person.firstName(), 26 | description: 'The first name of the user', 27 | }) 28 | @IsString() 29 | @IsOptional() 30 | public firstName?: string; 31 | 32 | @ApiProperty({ 33 | example: faker.person.lastName(), 34 | description: 'The last name of the user', 35 | }) 36 | @IsString() 37 | @IsOptional() 38 | public lastName?: string; 39 | 40 | @ApiProperty({ 41 | example: faker.internet.username(), 42 | description: 'The username of the user', 43 | }) 44 | @IsString() 45 | @IsOptional() 46 | public username?: string; 47 | } 48 | -------------------------------------------------------------------------------- /src/modules/auth/interfaces/auth.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ITokenResponse { 2 | accessToken: string; 3 | refreshToken: string; 4 | } 5 | 6 | export interface IAuthPayload { 7 | id: string; 8 | role: string; 9 | } 10 | 11 | export enum TokenType { 12 | ACCESS_TOKEN = 'AccessToken', 13 | REFRESH_TOKEN = 'RefreshToken', 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/auth/interfaces/auth.service.interface.ts: -------------------------------------------------------------------------------- 1 | import { IAuthPayload, ITokenResponse } from './auth.interface'; 2 | import { AuthLoginDto } from 'src/modules/auth/dtos/auth.login.dto'; 3 | import { AuthResponseDto } from 'src/modules/auth/dtos/auth.response.dto'; 4 | import { AuthSignupDto } from 'src/modules/auth/dtos/auth.signup.dto'; 5 | 6 | export interface IAuthService { 7 | verifyToken(accessToken: string): Promise; 8 | generateTokens(user: IAuthPayload): Promise; 9 | login(data: AuthLoginDto): Promise; 10 | signup(data: AuthSignupDto): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | 5 | import { HashService } from 'src/common/services/hash.service'; 6 | import { UserService } from 'src/modules/user/services/user.service'; 7 | 8 | import { AuthLoginDto } from 'src/modules/auth/dtos/auth.login.dto'; 9 | import { AuthResponseDto } from 'src/modules/auth/dtos/auth.response.dto'; 10 | import { AuthSignupDto } from 'src/modules/auth/dtos/auth.signup.dto'; 11 | import { 12 | IAuthPayload, 13 | ITokenResponse, 14 | TokenType, 15 | } from 'src/modules/auth/interfaces/auth.interface'; 16 | import { IAuthService } from 'src/modules/auth/interfaces/auth.service.interface'; 17 | 18 | @Injectable() 19 | export class AuthService implements IAuthService { 20 | private readonly accessTokenSecret: string; 21 | private readonly refreshTokenSecret: string; 22 | private readonly accessTokenExp: string; 23 | private readonly refreshTokenExp: string; 24 | 25 | constructor( 26 | private readonly configService: ConfigService, 27 | private readonly jwtService: JwtService, 28 | private readonly userService: UserService, 29 | private readonly hashService: HashService, 30 | ) { 31 | this.accessTokenSecret = this.configService.get('auth.accessToken.secret'); 32 | this.refreshTokenSecret = this.configService.get('auth.refreshToken.secret'); 33 | this.accessTokenExp = this.configService.get('auth.accessToken.expirationTime'); 34 | this.refreshTokenExp = this.configService.get('auth.refreshToken.expirationTime'); 35 | } 36 | 37 | async verifyToken(accessToken: string): Promise { 38 | try { 39 | const data = await this.jwtService.verifyAsync(accessToken, { 40 | secret: this.accessTokenSecret, 41 | }); 42 | 43 | return data; 44 | } catch (e) { 45 | throw e; 46 | } 47 | } 48 | 49 | async generateTokens(user: IAuthPayload): Promise { 50 | const accessTokenPromise = this.jwtService.signAsync( 51 | { 52 | id: user.id, 53 | role: user.role, 54 | tokenType: TokenType.ACCESS_TOKEN, 55 | }, 56 | { 57 | secret: this.accessTokenSecret, 58 | expiresIn: this.accessTokenExp, 59 | }, 60 | ); 61 | 62 | const refreshTokenPromise = this.jwtService.signAsync( 63 | { 64 | id: user.id, 65 | role: user.role, 66 | tokenType: TokenType.REFRESH_TOKEN, 67 | }, 68 | { 69 | secret: this.refreshTokenSecret, 70 | expiresIn: this.refreshTokenExp, 71 | }, 72 | ); 73 | 74 | const [accessToken, refreshToken] = await Promise.all([ 75 | accessTokenPromise, 76 | refreshTokenPromise, 77 | ]); 78 | 79 | return { 80 | accessToken, 81 | refreshToken, 82 | }; 83 | } 84 | 85 | async login(data: AuthLoginDto): Promise { 86 | try { 87 | const { email, password } = data; 88 | 89 | const user = await this.userService.getUserByEmail(email); 90 | 91 | if (!user) { 92 | throw new NotFoundException('user.userNotFound'); 93 | } 94 | 95 | const match = this.hashService.match(user.password, password); 96 | 97 | if (!match) { 98 | throw new NotFoundException('user.invalidPassword'); 99 | } 100 | 101 | const { accessToken, refreshToken } = await this.generateTokens({ 102 | id: user.id, 103 | role: user.role, 104 | }); 105 | 106 | return { 107 | accessToken, 108 | refreshToken, 109 | user, 110 | }; 111 | } catch (e) { 112 | throw e; 113 | } 114 | } 115 | 116 | async signup(data: AuthSignupDto): Promise { 117 | try { 118 | const { email, firstName, lastName, password } = data; 119 | const findByEmail = await this.userService.getUserByEmail(email); 120 | 121 | if (findByEmail) { 122 | throw new ConflictException('user.userExistsByEmail'); 123 | } 124 | 125 | const passwordHashed = this.hashService.createHash(password); 126 | 127 | const createdUser = await this.userService.createUser({ 128 | email, 129 | firstName: firstName?.trim(), 130 | lastName: lastName?.trim(), 131 | password: passwordHashed, 132 | }); 133 | 134 | const tokens = await this.generateTokens({ 135 | id: createdUser.id, 136 | role: createdUser.role, 137 | }); 138 | 139 | return { 140 | ...tokens, 141 | user: createdUser, 142 | }; 143 | } catch (e) { 144 | throw e; 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/modules/user/controllers/user.admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Delete, Param } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiParam, ApiTags } from '@nestjs/swagger'; 3 | import { Role } from '@prisma/client'; 4 | 5 | import { AllowedRoles } from 'src/common/decorators/role.decorator'; 6 | 7 | import { UserService } from 'src/modules/user/services/user.service'; 8 | 9 | @ApiTags('admin.user') 10 | @Controller({ 11 | version: '1', 12 | path: '/admin/user', 13 | }) 14 | export class AdminUserController { 15 | constructor(private readonly userService: UserService) {} 16 | 17 | @ApiBearerAuth('accessToken') 18 | @ApiParam({ 19 | name: 'id', 20 | type: 'string', 21 | }) 22 | @Delete(':id') 23 | @AllowedRoles([Role.ADMIN]) 24 | deleteUser(@Param('id') id: string): Promise { 25 | return this.userService.softDeleteUsers([id]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/user/controllers/user.auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Put } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 3 | import { Role } from '@prisma/client'; 4 | 5 | import { AuthUser } from 'src/common/decorators/auth.decorator'; 6 | import { AllowedRoles } from 'src/common/decorators/role.decorator'; 7 | import { IAuthPayload } from 'src/modules/auth/interfaces/auth.interface'; 8 | 9 | import { UserResponseDto } from 'src/modules/user/dtos/user.response.dto'; 10 | import { UserUpdateDto } from 'src/modules/user/dtos/user.update.dto'; 11 | import { UserService } from 'src/modules/user/services/user.service'; 12 | 13 | @ApiTags('auth.user') 14 | @Controller({ 15 | version: '1', 16 | path: '/user', 17 | }) 18 | export class AuthUserController { 19 | constructor(private readonly userService: UserService) {} 20 | 21 | @ApiBearerAuth('accessToken') 22 | @Put() 23 | @AllowedRoles([Role.USER, Role.ADMIN]) 24 | updateUser( 25 | @AuthUser() user: IAuthPayload, 26 | @Body() data: UserUpdateDto, 27 | ): Promise { 28 | return this.userService.updateUser(user.id, data); 29 | } 30 | 31 | @ApiBearerAuth('accessToken') 32 | @Get('profile') 33 | @AllowedRoles([Role.USER, Role.ADMIN]) 34 | getUserInfo(@AuthUser() user: IAuthPayload): Promise { 35 | return this.userService.getUserById(user.id); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/user/dtos/user.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { $Enums, User } from '@prisma/client'; 4 | import { Exclude } from 'class-transformer'; 5 | 6 | export class UserResponseDto implements User { 7 | @ApiProperty({ 8 | description: 'Unique identifier for the user', 9 | example: faker.string.uuid(), 10 | }) 11 | id: string; 12 | 13 | @ApiProperty({ 14 | description: 'Email address of the user', 15 | example: faker.internet.email(), 16 | }) 17 | email: string; 18 | 19 | @ApiProperty({ 20 | description: 'First name of the user', 21 | example: faker.person.firstName(), 22 | }) 23 | firstName: string; 24 | 25 | @ApiProperty({ 26 | description: 'Last name of the user', 27 | example: faker.person.lastName(), 28 | }) 29 | lastName: string; 30 | 31 | @ApiProperty({ 32 | description: 'Phone number of the user', 33 | example: faker.phone.number(), 34 | }) 35 | phoneNumber: string; 36 | 37 | @ApiProperty({ 38 | description: "URL of the user's profile picture", 39 | example: faker.image.avatar(), 40 | required: false, 41 | }) 42 | avatar: string; 43 | 44 | @ApiProperty({ 45 | description: "Indicates if the user's email is verified", 46 | example: true, 47 | }) 48 | isVerified: boolean; 49 | 50 | @ApiProperty({ 51 | description: 'Role of the user in the system', 52 | enum: $Enums.Role, 53 | example: $Enums.Role.USER, 54 | }) 55 | role: $Enums.Role; 56 | 57 | @ApiProperty({ 58 | description: 'The date and time when the user was created', 59 | example: faker.date.past().toISOString(), 60 | }) 61 | createdAt: Date; 62 | 63 | @ApiProperty({ 64 | description: 'The date and time when the user information was last updated', 65 | example: faker.date.recent().toISOString(), 66 | }) 67 | updatedAt: Date; 68 | 69 | @ApiProperty({ 70 | description: 'The date and time when the user was deleted, if applicable', 71 | example: null, 72 | required: false, 73 | nullable: true, 74 | }) 75 | deletedAt: Date | null; 76 | 77 | @Exclude() 78 | password: string; 79 | } 80 | -------------------------------------------------------------------------------- /src/modules/user/dtos/user.update.dto.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsOptional, IsString, IsEmail, Matches } from 'class-validator'; 4 | 5 | export class UserUpdateDto { 6 | @ApiProperty({ 7 | example: faker.internet.email(), 8 | description: 'Email address of the user', 9 | required: false, 10 | }) 11 | @IsEmail() 12 | @IsOptional() 13 | email?: string; 14 | 15 | @ApiProperty({ 16 | example: faker.phone.number(), 17 | description: 'Phone number of the user', 18 | required: false, 19 | }) 20 | @IsString() 21 | @Matches(/^\+?[1-9]\d{1,14}$/) 22 | @IsOptional() 23 | phoneNumber?: string; 24 | 25 | @ApiProperty({ 26 | example: faker.person.firstName(), 27 | description: 'First name of the user', 28 | required: false, 29 | }) 30 | @IsString() 31 | @IsOptional() 32 | firstName?: string; 33 | 34 | @ApiProperty({ 35 | example: faker.person.lastName(), 36 | description: 'Last name of the user', 37 | required: false, 38 | }) 39 | @IsString() 40 | @IsOptional() 41 | lastName?: string; 42 | 43 | @ApiProperty({ 44 | example: faker.image.avatar(), 45 | description: "User's profile picture URL", 46 | required: false, 47 | }) 48 | @IsString() 49 | @IsOptional() 50 | avatar?: string; 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/user/interfaces/user.service.interface.ts: -------------------------------------------------------------------------------- 1 | import { AuthSignupDto } from 'src/modules/auth/dtos/auth.signup.dto'; 2 | 3 | import { UserResponseDto } from 'src/modules/user/dtos/user.response.dto'; 4 | import { UserUpdateDto } from 'src/modules/user/dtos/user.update.dto'; 5 | 6 | export interface IUserService { 7 | updateUser(userId: number, data: UserUpdateDto): Promise; 8 | createUser(data: AuthSignupDto): Promise; 9 | getUserById(userId: number): Promise; 10 | getUserByEmail(email: string): Promise; 11 | softDeleteUsers(userIds: number[]): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/user/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import { PrismaService } from 'src/common/services/prisma.service'; 4 | import { AuthSignupDto } from 'src/modules/auth/dtos/auth.signup.dto'; 5 | 6 | import { UserResponseDto } from 'src/modules/user/dtos/user.response.dto'; 7 | import { UserUpdateDto } from 'src/modules/user/dtos/user.update.dto'; 8 | 9 | @Injectable() 10 | export class UserService { 11 | constructor(private readonly prismaService: PrismaService) {} 12 | 13 | async getUserById(userId: string): Promise { 14 | return this.prismaService.user.findUnique({ where: { id: userId } }); 15 | } 16 | 17 | async getUserByEmail(email: string): Promise { 18 | return this.prismaService.user.findUnique({ where: { email } }); 19 | } 20 | 21 | async updateUser(userId: string, data: UserUpdateDto) { 22 | const { firstName, lastName, email, phoneNumber, avatar } = data; 23 | return this.prismaService.user.update({ 24 | data: { 25 | firstName: firstName?.trim(), 26 | lastName: lastName?.trim(), 27 | email, 28 | phoneNumber, 29 | avatar, 30 | }, 31 | where: { 32 | id: userId, 33 | }, 34 | }); 35 | } 36 | 37 | async createUser(data: AuthSignupDto) { 38 | return this.prismaService.user.create({ 39 | data: { 40 | email: data?.email, 41 | password: data?.password, 42 | firstName: data?.firstName.trim(), 43 | lastName: data?.lastName.trim(), 44 | role: 'USER', 45 | }, 46 | }); 47 | } 48 | 49 | async softDeleteUsers(userIds: string[]) { 50 | await this.prismaService.user.updateMany({ 51 | where: { 52 | id: { 53 | in: userIds, 54 | }, 55 | }, 56 | data: { 57 | deletedAt: new Date(), 58 | }, 59 | }); 60 | return; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { CommonModule } from 'src/common/common.module'; 4 | 5 | import { AdminUserController } from './controllers/user.admin.controller'; 6 | import { AuthUserController } from './controllers/user.auth.controller'; 7 | import { UserService } from './services/user.service'; 8 | 9 | @Module({ 10 | controllers: [AuthUserController, AdminUserController], 11 | imports: [CommonModule], 12 | providers: [UserService], 13 | exports: [UserService], 14 | }) 15 | export class UserModule {} 16 | -------------------------------------------------------------------------------- /src/swagger.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Logger } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { DocumentBuilder, SwaggerCustomOptions, SwaggerModule } from '@nestjs/swagger'; 4 | 5 | export const setupSwagger = async (app: INestApplication) => { 6 | const configService = app.get(ConfigService); 7 | const logger = new Logger(); 8 | 9 | const docName: string = configService.get('doc.name'); 10 | const docDesc: string = configService.get('doc.description'); 11 | const docVersion: string = configService.get('doc.version'); 12 | const docPrefix: string = configService.get('doc.prefix'); 13 | 14 | const documentBuild = new DocumentBuilder() 15 | .setTitle(docName) 16 | .setDescription(docDesc) 17 | .setVersion(docVersion) 18 | .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'accessToken') 19 | .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'refreshToken') 20 | .build(); 21 | 22 | const document = SwaggerModule.createDocument(app, documentBuild, { 23 | deepScanRoutes: true, 24 | }); 25 | const customOptions: SwaggerCustomOptions = { 26 | swaggerOptions: { 27 | docExpansion: 'none', 28 | persistAuthorization: true, 29 | displayOperationId: true, 30 | operationsSorter: 'method', 31 | tagsSorter: 'alpha', 32 | tryItOutEnabled: true, 33 | filter: true, 34 | }, 35 | }; 36 | SwaggerModule.setup(docPrefix, app, document, { 37 | explorer: true, 38 | customSiteTitle: docName, 39 | ...customOptions, 40 | }); 41 | logger.log(`Docs will serve on ${docPrefix}`, 'NestApplication'); 42 | }; 43 | -------------------------------------------------------------------------------- /test/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException, NotFoundException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { Test, TestingModule } from '@nestjs/testing'; 5 | import { User } from '@prisma/client'; 6 | 7 | import { HashService } from 'src/common/services/hash.service'; 8 | import { AuthLoginDto } from 'src/modules/auth/dtos/auth.login.dto'; 9 | import { AuthSignupDto } from 'src/modules/auth/dtos/auth.signup.dto'; 10 | import { IAuthPayload } from 'src/modules/auth/interfaces/auth.interface'; 11 | import { AuthService } from 'src/modules/auth/services/auth.service'; 12 | import { UserResponseDto } from 'src/modules/user/dtos/user.response.dto'; 13 | import { UserService } from 'src/modules/user/services/user.service'; 14 | 15 | describe('AuthService', () => { 16 | let authService: AuthService; 17 | let jwtService: JwtService; 18 | let userService: UserService; 19 | let hashService: HashService; 20 | 21 | beforeEach(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [ 24 | AuthService, 25 | { 26 | provide: ConfigService, 27 | useValue: { 28 | get: jest.fn().mockImplementation((key: string) => { 29 | switch (key) { 30 | case 'auth.accessToken.secret': 31 | return 'access-secret'; 32 | case 'auth.refreshToken.secret': 33 | return 'refresh-secret'; 34 | case 'auth.accessToken.expirationTime': 35 | return '1h'; 36 | case 'auth.refreshToken.expirationTime': 37 | return '7d'; 38 | default: 39 | return null; 40 | } 41 | }), 42 | }, 43 | }, 44 | { provide: JwtService, useValue: { signAsync: jest.fn(), verifyAsync: jest.fn() } }, 45 | { 46 | provide: UserService, 47 | useValue: { getUserByEmail: jest.fn(), createUser: jest.fn() }, 48 | }, 49 | { provide: HashService, useValue: { match: jest.fn(), createHash: jest.fn() } }, 50 | ], 51 | }).compile(); 52 | 53 | authService = module.get(AuthService); 54 | jwtService = module.get(JwtService); 55 | userService = module.get(UserService); 56 | hashService = module.get(HashService); 57 | }); 58 | 59 | describe('verifyToken', () => { 60 | it('should verify a valid token and return decoded data', async () => { 61 | const mockToken = 'validToken'; 62 | const mockPayload: IAuthPayload = { id: 'user1', role: 'user' }; 63 | 64 | jest.spyOn(jwtService, 'verifyAsync').mockResolvedValue(mockPayload); 65 | 66 | const result = await authService.verifyToken(mockToken); 67 | 68 | expect(result).toEqual(mockPayload); 69 | expect(jwtService.verifyAsync).toHaveBeenCalledWith(mockToken, { 70 | secret: 'access-secret', 71 | }); 72 | }); 73 | 74 | it('should throw an error if token verification fails', async () => { 75 | const mockToken = 'invalidToken'; 76 | 77 | jest.spyOn(jwtService, 'verifyAsync').mockRejectedValue(new Error('Invalid token')); 78 | 79 | await expect(authService.verifyToken(mockToken)).rejects.toThrow('Invalid token'); 80 | }); 81 | }); 82 | 83 | describe('generateTokens', () => { 84 | it('should generate access and refresh tokens', async () => { 85 | const mockUser: IAuthPayload = { id: 'user1', role: 'user' }; 86 | const accessToken = 'accessToken'; 87 | const refreshToken = 'refreshToken'; 88 | 89 | jest.spyOn(jwtService, 'signAsync') 90 | .mockResolvedValueOnce(accessToken) 91 | .mockResolvedValueOnce(refreshToken); 92 | 93 | const result = await authService.generateTokens(mockUser); 94 | 95 | expect(result).toEqual({ accessToken, refreshToken }); 96 | expect(jwtService.signAsync).toHaveBeenCalledTimes(2); 97 | }); 98 | }); 99 | 100 | describe('login', () => { 101 | it('should throw NotFoundException if user does not exist', async () => { 102 | const loginDto: AuthLoginDto = { email: 'test@example.com', password: 'password' }; 103 | 104 | jest.spyOn(userService, 'getUserByEmail').mockResolvedValue(null); 105 | 106 | await expect(authService.login(loginDto)).rejects.toThrow(NotFoundException); 107 | }); 108 | 109 | it('should throw NotFoundException if password does not match', async () => { 110 | const loginDto: AuthLoginDto = { email: 'test@example.com', password: 'wrongPassword' }; 111 | const mockUser = { 112 | id: 'user1', 113 | email: 'test@example.com', 114 | password: 'hashedPassword', 115 | role: 'USER', 116 | } as User; 117 | 118 | jest.spyOn(userService, 'getUserByEmail').mockResolvedValue(mockUser); 119 | jest.spyOn(hashService, 'match').mockReturnValue(false); 120 | 121 | await expect(authService.login(loginDto)).rejects.toThrow(NotFoundException); 122 | }); 123 | 124 | it('should return tokens and user data on successful login', async () => { 125 | const loginDto: AuthLoginDto = { email: 'test@example.com', password: 'password' }; 126 | const mockUser = { 127 | id: 'user1', 128 | email: 'test@example.com', 129 | password: 'hashedPassword', 130 | role: 'USER', 131 | } as User; 132 | const accessToken = 'accessToken'; 133 | const refreshToken = 'refreshToken'; 134 | 135 | jest.spyOn(userService, 'getUserByEmail').mockResolvedValue(mockUser); 136 | jest.spyOn(hashService, 'match').mockReturnValue(true); 137 | jest.spyOn(authService, 'generateTokens').mockResolvedValue({ 138 | accessToken, 139 | refreshToken, 140 | }); 141 | 142 | const result = await authService.login(loginDto); 143 | 144 | expect(result).toEqual({ accessToken, refreshToken, user: mockUser }); 145 | }); 146 | 147 | it('should throw an error if token generation fails', async () => { 148 | const loginDto: AuthLoginDto = { email: 'test@example.com', password: 'password' }; 149 | const mockUser = { 150 | id: 'user1', 151 | email: 'test@example.com', 152 | password: 'hashedPassword', 153 | role: 'USER', 154 | } as User; 155 | 156 | jest.spyOn(userService, 'getUserByEmail').mockResolvedValue(mockUser); 157 | jest.spyOn(hashService, 'match').mockReturnValue(true); 158 | jest.spyOn(authService, 'generateTokens').mockRejectedValue( 159 | new Error('Token generation failed'), 160 | ); 161 | 162 | await expect(authService.login(loginDto)).rejects.toThrow('Token generation failed'); 163 | }); 164 | }); 165 | 166 | describe('signup', () => { 167 | it('should throw ConflictException if email is already used', async () => { 168 | const signupDto: AuthSignupDto = { 169 | email: 'test@example.com', 170 | firstName: 'Test', 171 | lastName: 'User', 172 | password: 'password', 173 | }; 174 | 175 | jest.spyOn(userService, 'getUserByEmail').mockResolvedValue({ 176 | id: 'user1', 177 | } as UserResponseDto); 178 | 179 | await expect(authService.signup(signupDto)).rejects.toThrow(ConflictException); 180 | }); 181 | 182 | it('should create a new user and return tokens and user data', async () => { 183 | const signupDto: AuthSignupDto = { 184 | email: 'test@example.com', 185 | firstName: 'Test', 186 | lastName: 'User', 187 | password: 'password', 188 | }; 189 | const mockUser = { id: 'user1', email: 'test@example.com', role: 'USER' } as User; 190 | const accessToken = 'accessToken'; 191 | const refreshToken = 'refreshToken'; 192 | const hashedPassword = 'hashedPassword'; 193 | 194 | jest.spyOn(userService, 'getUserByEmail').mockResolvedValue(null); 195 | jest.spyOn(hashService, 'createHash').mockReturnValue(hashedPassword); 196 | jest.spyOn(userService, 'createUser').mockResolvedValue(mockUser); 197 | jest.spyOn(authService, 'generateTokens').mockResolvedValue({ 198 | accessToken, 199 | refreshToken, 200 | }); 201 | 202 | const result = await authService.signup(signupDto); 203 | 204 | expect(result).toEqual({ accessToken, refreshToken, user: mockUser }); 205 | }); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /test/hash.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as bcrypt from 'bcrypt'; 3 | 4 | import { HashService } from 'src/common/services/hash.service'; 5 | 6 | jest.mock('bcrypt', () => ({ 7 | genSaltSync: jest.fn().mockReturnValue('some_salt'), 8 | hashSync: jest.fn().mockReturnValue('hashed_value'), 9 | compareSync: jest.fn(), 10 | })); 11 | 12 | describe('HelperHashService', () => { 13 | let service; 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | providers: [HashService], 18 | }).compile(); 19 | 20 | service = module.get(HashService); 21 | }); 22 | 23 | it('should be defined', () => { 24 | expect(service).toBeDefined(); 25 | }); 26 | 27 | describe('createHash', () => { 28 | it('should return a hash of the password', () => { 29 | const password = 'password123'; 30 | const hash = service.createHash(password); 31 | 32 | expect(bcrypt.genSaltSync).toHaveBeenCalled(); 33 | expect(bcrypt.hashSync).toHaveBeenCalledWith(password, 'some_salt'); 34 | expect(hash).toBe('hashed_value'); 35 | }); 36 | }); 37 | 38 | describe('match', () => { 39 | it('should return true if the password matches the hash', () => { 40 | const hash = 'hashed_value'; 41 | const password = 'password123'; 42 | 43 | jest.spyOn(bcrypt, 'compareSync').mockReturnValue(true); 44 | 45 | const isMatch = service.match(hash, password); 46 | 47 | expect(bcrypt.compareSync).toHaveBeenCalledWith(password, hash); 48 | expect(isMatch).toBe(true); 49 | }); 50 | 51 | it('should return false if the password does not match the hash', () => { 52 | const hash = 'hashed_value'; 53 | const password = 'wrong_password'; 54 | 55 | jest.spyOn(bcrypt, 'compareSync').mockReturnValue(false); 56 | 57 | const isMatch = service.match(hash, password); 58 | 59 | expect(bcrypt.compareSync).toHaveBeenCalledWith(password, hash); 60 | expect(isMatch).toBe(false); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "testTimeout": 5000, 3 | "rootDir": "../", 4 | "modulePaths": ["."], 5 | "testEnvironment": "node", 6 | "testMatch": ["/test/*.spec.ts"], 7 | "collectCoverage": true, 8 | "coverageDirectory": "coverage", 9 | "collectCoverageFrom": [ 10 | "/src/common/services/helper.hash.service.ts", 11 | "/src/modules/*/services/*.service.ts" 12 | ], 13 | "coverageThreshold": { 14 | "global": { 15 | "branches": 100, 16 | "functions": 100, 17 | "lines": 100, 18 | "statements": 100 19 | } 20 | }, 21 | "moduleFileExtensions": ["js", "ts", "json"], 22 | "transform": { 23 | "^.+\\.(t|j)s$": "ts-jest" 24 | }, 25 | "modulePathIgnorePatterns": ["/dist"] 26 | } 27 | -------------------------------------------------------------------------------- /test/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { PrismaService } from 'src/common/services/prisma.service'; 4 | import { AuthSignupDto } from 'src/modules/auth/dtos/auth.signup.dto'; 5 | import { UserUpdateDto } from 'src/modules/user/dtos/user.update.dto'; 6 | import { UserService } from 'src/modules/user/services/user.service'; 7 | 8 | describe('UserService', () => { 9 | let service: UserService; 10 | let prismaService: PrismaService; 11 | 12 | const prismaServiceMock = { 13 | user: { 14 | findUnique: jest.fn(), 15 | update: jest.fn(), 16 | create: jest.fn(), 17 | updateMany: jest.fn(), 18 | }, 19 | }; 20 | 21 | beforeEach(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [ 24 | UserService, 25 | { 26 | provide: PrismaService, 27 | useValue: prismaServiceMock, 28 | }, 29 | ], 30 | }).compile(); 31 | 32 | service = module.get(UserService); 33 | prismaService = module.get(PrismaService); 34 | }); 35 | 36 | afterEach(() => { 37 | jest.clearAllMocks(); 38 | }); 39 | 40 | describe('getUserById', () => { 41 | it('should return a user by ID', async () => { 42 | const mockUser = { 43 | id: '123', 44 | email: 'test@example.com', 45 | firstName: 'John', 46 | lastName: 'Doe', 47 | isVerified: true, 48 | role: 'USER', 49 | createdAt: new Date(), 50 | updatedAt: new Date(), 51 | deletedAt: null, 52 | isDeleted: false, 53 | phone: null, 54 | avatar: null, 55 | password: 'hashed_password', 56 | }; 57 | 58 | prismaServiceMock.user.findUnique.mockResolvedValue(mockUser); 59 | 60 | const result = await service.getUserById('123'); 61 | 62 | expect(result).toEqual(mockUser); 63 | expect(prismaService.user.findUnique).toHaveBeenCalledWith({ 64 | where: { id: '123' }, 65 | }); 66 | }); 67 | 68 | it('should return null when user not found', async () => { 69 | prismaServiceMock.user.findUnique.mockResolvedValue(null); 70 | 71 | const result = await service.getUserById('nonexistent'); 72 | 73 | expect(result).toBeNull(); 74 | expect(prismaService.user.findUnique).toHaveBeenCalledWith({ 75 | where: { id: 'nonexistent' }, 76 | }); 77 | }); 78 | }); 79 | 80 | describe('getUserByEmail', () => { 81 | it('should return a user by email', async () => { 82 | const mockUser = { 83 | id: '123', 84 | email: 'test@example.com', 85 | firstName: 'John', 86 | lastName: 'Doe', 87 | isVerified: true, 88 | role: 'USER', 89 | createdAt: new Date(), 90 | updatedAt: new Date(), 91 | deletedAt: null, 92 | isDeleted: false, 93 | phone: null, 94 | avatar: null, 95 | password: 'hashed_password', 96 | }; 97 | 98 | prismaServiceMock.user.findUnique.mockResolvedValue(mockUser); 99 | 100 | const result = await service.getUserByEmail('test@example.com'); 101 | 102 | expect(result).toEqual(mockUser); 103 | expect(prismaService.user.findUnique).toHaveBeenCalledWith({ 104 | where: { email: 'test@example.com' }, 105 | }); 106 | }); 107 | }); 108 | 109 | describe('updateUser', () => { 110 | it('should update a user', async () => { 111 | const userId = '123'; 112 | const updateData: UserUpdateDto = { 113 | firstName: 'Updated', 114 | lastName: 'Name', 115 | email: 'updated@example.com', 116 | phoneNumber: '1234567890', 117 | avatar: 'new-avatar.jpg', 118 | }; 119 | 120 | const updatedUser = { 121 | id: userId, 122 | email: updateData.email, 123 | firstName: updateData.firstName, 124 | lastName: updateData.lastName, 125 | phone: updateData.phoneNumber, 126 | avatar: updateData.avatar, 127 | isVerified: true, 128 | role: 'USER', 129 | createdAt: new Date(), 130 | updatedAt: new Date(), 131 | deletedAt: null, 132 | isDeleted: false, 133 | password: 'hashed_password', 134 | }; 135 | 136 | prismaServiceMock.user.update.mockResolvedValue(updatedUser); 137 | 138 | const result = await service.updateUser(userId, updateData); 139 | 140 | expect(result).toEqual(updatedUser); 141 | expect(prismaService.user.update).toHaveBeenCalledWith({ 142 | data: { 143 | firstName: updateData.firstName?.trim(), 144 | lastName: updateData.lastName?.trim(), 145 | email: updateData.email, 146 | phoneNumber: updateData.phoneNumber, 147 | avatar: updateData.avatar, 148 | }, 149 | where: { id: userId }, 150 | }); 151 | }); 152 | }); 153 | 154 | describe('createUser', () => { 155 | it('should create a new user', async () => { 156 | const signupData: AuthSignupDto = { 157 | email: 'new@example.com', 158 | password: 'password123', 159 | firstName: 'New', 160 | lastName: 'User', 161 | username: 'newuser', 162 | }; 163 | 164 | const createdUser = { 165 | id: '123', 166 | email: signupData.email, 167 | password: signupData.password, 168 | firstName: signupData.firstName, 169 | lastName: signupData.lastName, 170 | role: 'USER', 171 | isVerified: false, 172 | createdAt: new Date(), 173 | updatedAt: new Date(), 174 | deletedAt: null, 175 | isDeleted: false, 176 | phone: null, 177 | avatar: null, 178 | }; 179 | 180 | prismaServiceMock.user.create.mockResolvedValue(createdUser); 181 | 182 | const result = await service.createUser(signupData); 183 | 184 | expect(result).toEqual(createdUser); 185 | expect(prismaService.user.create).toHaveBeenCalledWith({ 186 | data: { 187 | email: signupData.email, 188 | password: signupData.password, 189 | firstName: signupData.firstName.trim(), 190 | lastName: signupData.lastName.trim(), 191 | role: 'USER', 192 | }, 193 | }); 194 | }); 195 | }); 196 | 197 | describe('softDeleteUsers', () => { 198 | it('should soft delete multiple users', async () => { 199 | const userIds = ['123', '456', '789']; 200 | 201 | prismaServiceMock.user.updateMany.mockResolvedValue({ 202 | count: userIds.length, 203 | }); 204 | 205 | await service.softDeleteUsers(userIds); 206 | 207 | expect(prismaService.user.updateMany).toHaveBeenCalledWith({ 208 | where: { 209 | id: { 210 | in: userIds, 211 | }, 212 | }, 213 | data: { 214 | deletedAt: expect.any(Date), 215 | }, 216 | }); 217 | }); 218 | 219 | it('should handle empty user ids array', async () => { 220 | const userIds: string[] = []; 221 | 222 | await service.softDeleteUsers(userIds); 223 | 224 | expect(prismaService.user.updateMany).toHaveBeenCalledWith({ 225 | where: { 226 | id: { 227 | in: [], 228 | }, 229 | }, 230 | data: { 231 | deletedAt: expect.any(Date), 232 | }, 233 | }); 234 | }); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "useDefineForClassFields": false, 10 | "target": "ESNext", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "allowJs": false, 17 | "esModuleInterop": true, 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "types": ["node", "jest"] 21 | }, 22 | "include": ["src", "test"], 23 | "exclude": ["node_modules", "dist", "*coverage"] 24 | } 25 | --------------------------------------------------------------------------------