├── .eslintrc.js ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .prettierrc ├── README.md ├── commitlint.config.js ├── env-example ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.module.ts ├── auth-google │ ├── auth-google.controller.ts │ ├── auth-google.module.ts │ ├── auth-google.service.ts │ ├── auth-google.ts │ ├── dtos │ │ └── auth-google-login.dto.ts │ ├── strategies │ │ └── google.strategy.ts │ └── tests │ │ ├── auth-google.controller.spec.ts │ │ └── auth-google.service.spec.ts ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── auth.ts │ ├── dtos │ │ ├── auth-confirm-email.dto.ts │ │ ├── auth-email-login.dto.ts │ │ ├── auth-forgot-password.dto.ts │ │ ├── auth-register.dto.ts │ │ └── auth-reset-password.dto.ts │ ├── enums │ │ └── auth-providers.enum.ts │ ├── strategies │ │ ├── anonymous.strategy.ts │ │ ├── jwt-refresh.strategy.ts │ │ ├── jwt.strategy.ts │ │ └── types │ │ │ ├── jwt-payload.type.ts │ │ │ └── jwt-refresh-payload.type.ts │ ├── tests │ │ ├── auth.controller.spec.ts │ │ └── auth.service.spec.ts │ └── types │ │ └── login-response.type.ts ├── config │ ├── app.config.ts │ ├── auth.config.ts │ ├── config.type.ts │ ├── database.config.ts │ ├── google.config.ts │ └── mailer.config.ts ├── database │ ├── data-source.ts │ └── typeorm-config.service.ts ├── forgot-password │ ├── entities │ │ └── forgot-password.entity.ts │ ├── forgot-password.module.ts │ ├── forgot-password.service.ts │ ├── forgot-password.ts │ └── tests │ │ └── forgot-password.service.spec.ts ├── mailer │ ├── mailer.module.ts │ ├── mailer.service.ts │ ├── mailer.ts │ └── tests │ │ └── mailer.service.spec.ts ├── mails │ ├── mails.module.ts │ ├── mails.service.ts │ ├── mails.ts │ ├── templates │ │ ├── confirm.hbs │ │ └── reset-password.hbs │ ├── tests │ │ └── mails.service.spec.ts │ └── types │ │ └── mails.type.ts ├── main.ts ├── session │ ├── entities │ │ └── session.entity.ts │ ├── session.module.ts │ ├── session.service.ts │ ├── session.ts │ └── tests │ │ └── session.service.spec.ts ├── social │ └── types │ │ └── social.type.ts ├── users │ ├── dtos │ │ └── create-user.dto.ts │ ├── entities │ │ └── user.entity.ts │ ├── tests │ │ ├── users.controller.spec.ts │ │ └── users.service.spec.ts │ ├── users.controller.ts │ ├── users.module.ts │ ├── users.service.ts │ └── users.ts └── utils │ ├── constants.ts │ ├── entity-helper.ts │ ├── helpers.ts │ ├── transformers │ └── lower-case.transformer.ts │ ├── types │ ├── entity-condition.type.ts │ ├── find-options.type.ts │ ├── infinity-pagination.type.ts │ ├── maybe.type.ts │ ├── nullable.type.ts │ ├── or-never.type.ts │ └── pagination-options.ts │ ├── validate-config.ts │ └── validation-options.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | 'no-unused-vars': 'off', 25 | '@typescript-eslint/no-unused-vars': ['error'], 26 | 'require-await': 'off', 27 | '@typescript-eslint/require-await': 'error', 28 | '@typescript-eslint/no-floating-promises': 'error', 29 | 30 | '@typescript-eslint/ban-types': [ 31 | 'error', 32 | { 33 | extendDefaults: true, 34 | types: { 35 | Function: false, 36 | }, 37 | }, 38 | ], 39 | 'no-restricted-syntax': [ 40 | 'error', 41 | { 42 | selector: 43 | 'CallExpression[callee.object.name=configService][callee.property.name=/^(get|getOrThrow)$/]:not(:has([arguments.1] Property[key.name=infer][value.value=true])), CallExpression[callee.object.property.name=configService][callee.property.name=/^(get|getOrThrow)$/]:not(:has([arguments.1] Property[key.name=infer][value.value=true]))', 44 | message: 45 | 'Add "{ infer: true }" to configService.get() for correct typechecking. Example: configService.get("database.port", { infer: true })', 46 | }, 47 | ], 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /.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 | 37 | .data 38 | /files 39 | .env* 40 | /ormconfig.json -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run build -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS FULL AUTH boilerplate 2 | 3 | ## Description 4 | 5 | NestJS boilerplate, Auth, TypeORM, MySql, Mailing, Google OAuth20 6 | 7 | ## Table of Contents 8 | 9 | - [Features](#features) 10 | - [Quick run](#quick-run) 11 | - [Links](#links) 12 | 13 | ## Features 14 | 15 | - [x] Database ([typeorm](https://www.npmjs.com/package/typeorm)). 16 | - [x] Config Service ([@nestjs/config](https://www.npmjs.com/package/@nestjs/config)). 17 | - [x] Mailing ([nodemailer](https://www.npmjs.com/package/nodemailer)). 18 | - [x] Sign in and sign up via email. 19 | - [x] Confirm account via email verification. 20 | - [x] Forget password. 21 | - [x] Reset password. 22 | - [x] Refresh token. 23 | - [x] Google OAuth20. 24 | - [x] Swagger. 25 | 26 | ## Quick run 27 | 28 | ```bash 29 | git clone https://github.com/RamezTaher/nestjs-full-auth 30 | cd nestjs-full-auth 31 | npm install 32 | cp env-example .env 33 | npm run start:dev 34 | ``` 35 | 36 | ## Links 37 | 38 | - Swagger: 39 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) 2 | // ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) 3 | // docs: Documentation only changes 4 | // feat: A new feature 5 | // fix: A bug fix 6 | // perf: A code change that improves performance 7 | // refactor: A code change that neither fixes a bug nor adds a feature 8 | // style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 9 | // test: Adding missing tests or correcting existing tests 10 | 11 | module.exports = { 12 | extends: ['@commitlint/config-conventional'], 13 | rules: { 14 | 'body-leading-blank': [1, 'always'], 15 | 'body-max-line-length': [2, 'always', 100], 16 | 'footer-leading-blank': [1, 'always'], 17 | 'footer-max-line-length': [2, 'always', 100], 18 | 'header-max-length': [2, 'always', 100], 19 | 'scope-case': [2, 'always', 'lower-case'], 20 | 'subject-case': [ 21 | 2, 22 | 'never', 23 | ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], 24 | ], 25 | 'subject-empty': [2, 'never'], 26 | 'subject-full-stop': [2, 'never', '.'], 27 | 'type-case': [2, 'always', 'lower-case'], 28 | 'type-empty': [2, 'never'], 29 | 'type-enum': [ 30 | 2, 31 | 'always', 32 | [ 33 | 'build', 34 | 'chore', 35 | 'ci', 36 | 'docs', 37 | 'feat', 38 | 'fix', 39 | 'perf', 40 | 'refactor', 41 | 'revert', 42 | 'style', 43 | 'test', 44 | 'translation', 45 | 'security', 46 | 'changeset', 47 | ], 48 | ], 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /env-example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | APP_PORT=5000 3 | APP_NAME="NestJS API" 4 | API_PREFIX=api 5 | FRONTEND_DOMAIN=http://localhost:3000 6 | BACKEND_DOMAIN=http://localhost:5000 7 | 8 | DATABASE_TYPE=mysql 9 | DATABASE_URL= 10 | DATABASE_HOST=localhost 11 | DATABASE_PORT=3306 12 | DATABASE_USERNAME= 13 | DATABASE_PASSWORD= 14 | DATABASE_NAME=test 15 | DATABASE_SYNCHRONIZE=true 16 | 17 | 18 | AUTH_JWT_SECRET= 19 | AUTH_JWT_TOKEN_EXPIRES_IN= 20 | AUTH_REFRESH_SECRET= 21 | AUTH_REFRESH_TOKEN_EXPIRES_IN= 22 | 23 | 24 | 25 | MAILER_HOST= 26 | MAILER_PORT= 27 | MAILER_USER= 28 | MAILER_PASSWORD= 29 | MAILER_IGNORE_TLS=false 30 | MAILER_SECURE=false 31 | MAILER_REQUIRE_TLS=false 32 | MAILER_DEFAULT_EMAIL=noreply@example.com 33 | MAILER_DEFAULT_NAME= 34 | MAILER_CLIENT_PORT=1080 35 | 36 | 37 | 38 | GOOGLE_CLIENT_ID= 39 | GOOGLE_CLIENT_SECRET= 40 | CALL_BACK_URL= 41 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-full-auth", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json", 22 | "prepare": "husky install" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^9.0.0", 26 | "@nestjs/config": "^3.0.0", 27 | "@nestjs/core": "^9.0.0", 28 | "@nestjs/jwt": "^10.1.0", 29 | "@nestjs/passport": "^10.0.0", 30 | "@nestjs/platform-express": "^9.0.0", 31 | "@nestjs/swagger": "^7.1.8", 32 | "@nestjs/typeorm": "^10.0.0", 33 | "bcrypt": "^5.1.0", 34 | "bcryptjs": "^2.4.3", 35 | "class-transformer": "^0.5.1", 36 | "class-validator": "^0.14.0", 37 | "dotenv": "^16.3.1", 38 | "express-session": "^1.17.3", 39 | "google-auth-library": "^9.0.0", 40 | "handlebars": "^4.7.8", 41 | "ms": "^2.1.3", 42 | "mysql2": "^3.6.0", 43 | "nodemailer": "^6.9.4", 44 | "passport": "^0.6.0", 45 | "passport-anonymous": "^1.0.1", 46 | "passport-google-oauth20": "^2.0.0", 47 | "passport-jwt": "^4.0.1", 48 | "reflect-metadata": "^0.1.13", 49 | "rimraf": "^3.0.2", 50 | "rxjs": "^7.2.0", 51 | "swagger-ui-express": "^5.0.0", 52 | "typeorm": "^0.3.17" 53 | }, 54 | "devDependencies": { 55 | "@commitlint/cli": "^17.7.0", 56 | "@commitlint/config-conventional": "^17.7.0", 57 | "@nestjs/cli": "^9.0.0", 58 | "@nestjs/schematics": "^9.0.0", 59 | "@nestjs/testing": "^9.0.0", 60 | "@types/bcryptjs": "^2.4.2", 61 | "@types/express": "^4.17.13", 62 | "@types/express-session": "^1.17.7", 63 | "@types/jest": "28.1.8", 64 | "@types/ms": "^0.7.31", 65 | "@types/node": "^16.0.0", 66 | "@types/nodemailer": "^6.4.9", 67 | "@types/passport-anonymous": "^1.0.3", 68 | "@types/passport-google-oauth20": "^2.0.11", 69 | "@types/passport-jwt": "^3.0.9", 70 | "@types/supertest": "^2.0.11", 71 | "@typescript-eslint/eslint-plugin": "^5.0.0", 72 | "@typescript-eslint/parser": "^5.0.0", 73 | "eslint": "^8.0.1", 74 | "eslint-config-prettier": "^8.3.0", 75 | "eslint-plugin-prettier": "^4.0.0", 76 | "husky": "^8.0.3", 77 | "jest": "28.1.3", 78 | "prettier": "^2.3.2", 79 | "source-map-support": "^0.5.20", 80 | "supertest": "^6.1.3", 81 | "ts-jest": "28.0.8", 82 | "ts-loader": "^9.2.3", 83 | "ts-node": "^10.0.0", 84 | "tsconfig-paths": "4.1.0", 85 | "typescript": "^4.7.4" 86 | }, 87 | "jest": { 88 | "moduleFileExtensions": [ 89 | "js", 90 | "json", 91 | "ts" 92 | ], 93 | "rootDir": "src", 94 | "testRegex": ".*\\.spec\\.ts$", 95 | "transform": { 96 | "^.+\\.(t|j)s$": "ts-jest" 97 | }, 98 | "collectCoverageFrom": [ 99 | "**/*.(t|j)s" 100 | ], 101 | "coverageDirectory": "../coverage", 102 | "testEnvironment": "node" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import databaseConfig from './config/database.config'; 5 | import { TypeOrmConfigService } from './database/typeorm-config.service'; 6 | import { DataSource, DataSourceOptions } from 'typeorm'; 7 | import { UsersModule } from './users/users.module'; 8 | import { AuthModule } from './auth/auth.module'; 9 | import { SessionModule } from './session/session.module'; 10 | import { MailerModule } from './mailer/mailer.module'; 11 | import { MailsModule } from './mails/mails.module'; 12 | import { ForgotPasswordModule } from './forgot-password/forgot-password.module'; 13 | import { AuthGoogleModule } from './auth-google/auth-google.module'; 14 | import appConfig from './config/app.config'; 15 | import authConfig from './config/auth.config'; 16 | import mailerConfig from './config/mailer.config'; 17 | import googleConfig from './config/google.config'; 18 | 19 | @Module({ 20 | imports: [ 21 | ConfigModule.forRoot({ 22 | isGlobal: true, 23 | load: [databaseConfig, appConfig, authConfig, mailerConfig, googleConfig], 24 | envFilePath: ['.env'], 25 | }), 26 | TypeOrmModule.forRootAsync({ 27 | useClass: TypeOrmConfigService, 28 | dataSourceFactory: async (options: DataSourceOptions) => { 29 | const dataSource = await new DataSource(options).initialize(); 30 | return dataSource; 31 | }, 32 | }), 33 | UsersModule, 34 | AuthModule, 35 | SessionModule, 36 | MailerModule, 37 | MailsModule, 38 | ForgotPasswordModule, 39 | AuthGoogleModule, 40 | ], 41 | }) 42 | export class AppModule {} 43 | -------------------------------------------------------------------------------- /src/auth-google/auth-google.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Inject, Get, UseGuards, Req } from '@nestjs/common'; 2 | 3 | import { Routes, Services } from 'src/utils/constants'; 4 | import { IAuthService } from 'src/auth/auth'; 5 | import { AuthProvidersEnum } from 'src/auth/enums/auth-providers.enum'; 6 | import { ApiTags } from '@nestjs/swagger'; 7 | import { AuthGuard } from '@nestjs/passport'; 8 | 9 | @ApiTags('Auth') 10 | @Controller(Routes.AUTH) 11 | export class AuthGoogleController { 12 | constructor( 13 | @Inject(Services.AUTH) private readonly authService: IAuthService, 14 | ) {} 15 | 16 | @Get('google/login') 17 | @UseGuards(AuthGuard('google')) 18 | async googleLogin() { 19 | // Initiates the Google OAuth2 authentication process 20 | } 21 | 22 | @Get('google/redirect') 23 | @UseGuards(AuthGuard('google')) 24 | async googleLoginCallback(@Req() req: any) { 25 | const user = await this.authService.validateSocialLogin( 26 | AuthProvidersEnum.google, 27 | { 28 | id: req.user.user.id, 29 | firstName: req.user.user.firstName, 30 | lastName: req.user.user.lastName, 31 | email: req.user.user.email, 32 | }, 33 | ); 34 | return user; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/auth-google/auth-google.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthGoogleService } from './auth-google.service'; 3 | import { AuthGoogleController } from './auth-google.controller'; 4 | import { AuthModule } from 'src/auth/auth.module'; 5 | 6 | import { ConfigModule } from '@nestjs/config'; 7 | import { Services } from 'src/utils/constants'; 8 | import { JwtStrategy } from 'src/auth/strategies/jwt.strategy'; 9 | import { GoogleStrategy } from './strategies/google.strategy'; 10 | import { PassportModule } from '@nestjs/passport'; 11 | 12 | @Module({ 13 | imports: [AuthModule, ConfigModule, PassportModule], 14 | providers: [ 15 | JwtStrategy, 16 | GoogleStrategy, 17 | { 18 | provide: Services.AUTH_GOOGLE, 19 | useClass: AuthGoogleService, 20 | }, 21 | ], 22 | exports: [ 23 | { 24 | provide: Services.AUTH_GOOGLE, 25 | useClass: AuthGoogleService, 26 | }, 27 | ], 28 | controllers: [AuthGoogleController], 29 | }) 30 | export class AuthGoogleModule {} 31 | -------------------------------------------------------------------------------- /src/auth-google/auth-google.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { OAuth2Client } from 'google-auth-library'; 4 | import { AllConfigType } from 'src/config/config.type'; 5 | import { AuthGoogleLoginDto } from './dtos/auth-google-login.dto'; 6 | import { SocialType } from 'src/social/types/social.type'; 7 | import { IAuthGoogleService } from './auth-google'; 8 | 9 | @Injectable() 10 | export class AuthGoogleService implements IAuthGoogleService { 11 | private google: OAuth2Client; 12 | 13 | constructor(private configService: ConfigService) { 14 | this.google = new OAuth2Client( 15 | configService.get('google.clientId', { infer: true }), 16 | configService.get('google.clientSecret', { infer: true }), 17 | ); 18 | } 19 | 20 | async getProfileByToken(loginDto: AuthGoogleLoginDto): Promise { 21 | const ticket = await this.google.verifyIdToken({ 22 | idToken: loginDto.idToken, 23 | audience: [ 24 | this.configService.getOrThrow('google.clientId', { 25 | infer: true, 26 | }), 27 | ], 28 | }); 29 | 30 | const data = ticket.getPayload(); 31 | 32 | if (!data) { 33 | throw new HttpException( 34 | { 35 | status: HttpStatus.UNPROCESSABLE_ENTITY, 36 | errors: { 37 | user: 'wrongToken', 38 | }, 39 | }, 40 | HttpStatus.UNPROCESSABLE_ENTITY, 41 | ); 42 | } 43 | 44 | return { 45 | id: data.sub, 46 | email: data.email, 47 | firstName: data.given_name, 48 | lastName: data.family_name, 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/auth-google/auth-google.ts: -------------------------------------------------------------------------------- 1 | import { SocialType } from 'src/social/types/social.type'; 2 | import { AuthGoogleLoginDto } from './dtos/auth-google-login.dto'; 3 | 4 | export interface IAuthGoogleService { 5 | getProfileByToken(loginDto: AuthGoogleLoginDto): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/auth-google/dtos/auth-google-login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class AuthGoogleLoginDto { 5 | @ApiProperty({ example: '12asa262sa5sa' }) 6 | @IsNotEmpty() 7 | idToken: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/auth-google/strategies/google.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Profile, Strategy } from 'passport-google-oauth20'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { AllConfigType } from 'src/config/config.type'; 6 | import { Services } from 'src/utils/constants'; 7 | import { IAuthService } from 'src/auth/auth'; 8 | import { AuthProvidersEnum } from 'src/auth/enums/auth-providers.enum'; 9 | 10 | @Injectable() 11 | export class GoogleStrategy extends PassportStrategy(Strategy) { 12 | constructor( 13 | @Inject(Services.AUTH) private readonly authService: IAuthService, 14 | private readonly configService: ConfigService, 15 | ) { 16 | super({ 17 | clientID: configService.get('google.clientId', { 18 | infer: true, 19 | }), 20 | clientSecret: configService.get('google.clientSecret', { 21 | infer: true, 22 | }), 23 | callbackURL: configService.get('google.callbackURL', { 24 | infer: true, 25 | }), 26 | scope: ['profile', 'email'], 27 | }); 28 | } 29 | 30 | async validate(accessToken: string, refreshToken: string, profile: Profile) { 31 | const socialData = { 32 | id: profile.id, 33 | email: profile.emails ? profile.emails[0].value : null, 34 | firstName: profile.name.givenName, 35 | lastName: profile.name.familyName, 36 | }; 37 | 38 | // Assuming you have a method to handle social login validation 39 | const loginResponse = await this.authService.validateSocialLogin( 40 | AuthProvidersEnum.google, 41 | socialData, 42 | ); 43 | 44 | return loginResponse; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/auth-google/tests/auth-google.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthGoogleController } from '../auth-google.controller'; 3 | 4 | describe('AuthGoogleController', () => { 5 | let controller: AuthGoogleController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AuthGoogleController], 10 | }).compile(); 11 | 12 | controller = module.get(AuthGoogleController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth-google/tests/auth-google.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthGoogleService } from '../auth-google.service'; 3 | 4 | describe('AuthGoogleService', () => { 5 | let service: AuthGoogleService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthGoogleService], 10 | }).compile(); 11 | 12 | service = module.get(AuthGoogleService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | HttpStatus, 6 | Inject, 7 | Post, 8 | Get, 9 | UseGuards, 10 | Request, 11 | } from '@nestjs/common'; 12 | import { Routes, Services } from 'src/utils/constants'; 13 | import { IAuthService } from './auth'; 14 | import { AuthEmailLoginDto } from './dtos/auth-email-login.dto'; 15 | import { LoginResponseType } from './types/login-response.type'; 16 | import { AuthRegisterDto } from './dtos/auth-register.dto'; 17 | import { AuthConfirmEmailDto } from './dtos/auth-confirm-email.dto'; 18 | import { AuthGuard } from '@nestjs/passport'; 19 | import { NullableType } from 'src/utils/types/nullable.type'; 20 | import { User } from 'src/users/entities/user.entity'; 21 | import { AuthForgotPasswordDto } from './dtos/auth-forgot-password.dto'; 22 | import { AuthResetPasswordDto } from './dtos/auth-reset-password.dto'; 23 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 24 | 25 | @ApiTags('Auth') 26 | @Controller(Routes.AUTH) 27 | export class AuthController { 28 | constructor( 29 | @Inject(Services.AUTH) private readonly authService: IAuthService, 30 | ) {} 31 | 32 | @Post('login') 33 | @HttpCode(HttpStatus.OK) 34 | login(@Body() loginDto: AuthEmailLoginDto): Promise { 35 | return this.authService.validateLogin(loginDto); 36 | } 37 | 38 | @Post('register') 39 | @HttpCode(HttpStatus.NO_CONTENT) 40 | async register(@Body() createUserDto: AuthRegisterDto): Promise { 41 | return await this.authService.registerUser(createUserDto); 42 | } 43 | 44 | @Post('confirm-email') 45 | @HttpCode(HttpStatus.NO_CONTENT) 46 | async confirmEmail( 47 | @Body() confirmEmailDto: AuthConfirmEmailDto, 48 | ): Promise { 49 | return this.authService.confirmEmail(confirmEmailDto.hash); 50 | } 51 | 52 | @ApiBearerAuth() 53 | @Get('status') 54 | @UseGuards(AuthGuard('jwt')) 55 | @HttpCode(HttpStatus.OK) 56 | public status(@Request() request): Promise> { 57 | console.log(request.user); 58 | return this.authService.status(request.user); 59 | } 60 | 61 | @Post('forgot-password') 62 | @HttpCode(HttpStatus.NO_CONTENT) 63 | async forgotPassword( 64 | @Body() forgotPasswordDto: AuthForgotPasswordDto, 65 | ): Promise { 66 | return this.authService.forgotPassword(forgotPasswordDto.email); 67 | } 68 | 69 | @Post('reset-password') 70 | @HttpCode(HttpStatus.NO_CONTENT) 71 | resetPassword(@Body() resetPasswordDto: AuthResetPasswordDto): Promise { 72 | return this.authService.resetPassword( 73 | resetPasswordDto.hash, 74 | resetPasswordDto.password, 75 | ); 76 | } 77 | 78 | @ApiBearerAuth() 79 | @Post('refresh') 80 | @UseGuards(AuthGuard('jwt-refresh')) 81 | @HttpCode(HttpStatus.OK) 82 | public refresh(@Request() request): Promise> { 83 | return this.authService.refreshToken({ 84 | sessionId: request.user.sessionId, 85 | }); 86 | } 87 | 88 | @ApiBearerAuth() 89 | @Post('logout') 90 | @UseGuards(AuthGuard('jwt')) 91 | @HttpCode(HttpStatus.NO_CONTENT) 92 | public async logout(@Request() request): Promise { 93 | await this.authService.logout({ 94 | sessionId: request.user.sessionId, 95 | }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthController } from './auth.controller'; 4 | import { Services } from 'src/utils/constants'; 5 | import { UsersModule } from 'src/users/users.module'; 6 | import { SessionModule } from 'src/session/session.module'; 7 | import { PassportModule } from '@nestjs/passport'; 8 | import { JwtModule } from '@nestjs/jwt'; 9 | import { JwtStrategy } from './strategies/jwt.strategy'; 10 | import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; 11 | import { AnonymousStrategy } from './strategies/anonymous.strategy'; 12 | import { MailsModule } from 'src/mails/mails.module'; 13 | import { ForgotPasswordModule } from 'src/forgot-password/forgot-password.module'; 14 | 15 | @Module({ 16 | imports: [ 17 | UsersModule, 18 | SessionModule, 19 | MailsModule, 20 | PassportModule, 21 | ForgotPasswordModule, 22 | JwtModule.register({}), 23 | ], 24 | 25 | controllers: [AuthController], 26 | providers: [ 27 | JwtRefreshStrategy, 28 | JwtStrategy, 29 | AnonymousStrategy, 30 | { 31 | provide: Services.AUTH, 32 | useClass: AuthService, 33 | }, 34 | ], 35 | exports: [ 36 | { 37 | provide: Services.AUTH, 38 | useClass: AuthService, 39 | }, 40 | ], 41 | }) 42 | export class AuthModule {} 43 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpException, 3 | HttpStatus, 4 | Inject, 5 | Injectable, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import { IUsersService } from 'src/users/users'; 9 | import { Services } from 'src/utils/constants'; 10 | import { AuthEmailLoginDto } from './dtos/auth-email-login.dto'; 11 | import { LoginResponseType } from './types/login-response.type'; 12 | import { AuthProvidersEnum } from './enums/auth-providers.enum'; 13 | import crypto from 'crypto'; 14 | 15 | import { User, UserStatus } from 'src/users/entities/user.entity'; 16 | import { ISessionService } from 'src/session/session'; 17 | import { Session } from 'src/session/entities/session.entity'; 18 | import { ConfigService } from '@nestjs/config'; 19 | import { AllConfigType } from 'src/config/config.type'; 20 | import ms from 'ms'; 21 | import { JwtService } from '@nestjs/jwt'; 22 | import { IAuthService } from './auth'; 23 | import { AuthRegisterDto } from './dtos/auth-register.dto'; 24 | import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; 25 | import { compareHash } from 'src/utils/helpers'; 26 | import { IMailsService } from 'src/mails/mails'; 27 | import { JwtPayloadType } from './strategies/types/jwt-payload.type'; 28 | import { NullableType } from 'src/utils/types/nullable.type'; 29 | import { IForgotPasswordService } from 'src/forgot-password/forgot-password'; 30 | import { SocialType } from 'src/social/types/social.type'; 31 | import { JwtRefreshPayloadType } from './strategies/types/jwt-refresh-payload.type'; 32 | 33 | @Injectable() 34 | export class AuthService implements IAuthService { 35 | constructor( 36 | @Inject(Services.USERS) private readonly usersService: IUsersService, 37 | @Inject(Services.MAILS) private readonly mailsService: IMailsService, 38 | @Inject(Services.SESSION) private readonly sessionService: ISessionService, 39 | @Inject(Services.FORGOT_PASSWORD) 40 | private readonly forgotPasswordService: IForgotPasswordService, 41 | 42 | private readonly configService: ConfigService, 43 | private readonly jwtService: JwtService, 44 | ) {} 45 | 46 | async validateLogin(loginDto: AuthEmailLoginDto): Promise { 47 | const user = await this.usersService.findOneUser({ 48 | email: loginDto.email, 49 | }); 50 | 51 | if (!user) { 52 | throw new HttpException( 53 | { 54 | status: HttpStatus.UNPROCESSABLE_ENTITY, 55 | errors: { 56 | email: 'notFound', 57 | }, 58 | }, 59 | HttpStatus.UNPROCESSABLE_ENTITY, 60 | ); 61 | } 62 | 63 | if (user.provider !== AuthProvidersEnum.email) { 64 | throw new HttpException( 65 | { 66 | status: HttpStatus.UNPROCESSABLE_ENTITY, 67 | errors: { 68 | email: `needLoginViaProvider:${user.provider}`, 69 | }, 70 | }, 71 | HttpStatus.UNPROCESSABLE_ENTITY, 72 | ); 73 | } 74 | 75 | const isValidPassword = await compareHash(loginDto.password, user.password); 76 | 77 | if (!isValidPassword) { 78 | throw new HttpException( 79 | { 80 | status: HttpStatus.UNPROCESSABLE_ENTITY, 81 | errors: { 82 | password: 'incorrectPassword', 83 | }, 84 | }, 85 | HttpStatus.UNPROCESSABLE_ENTITY, 86 | ); 87 | } 88 | 89 | const session = await this.sessionService.create({ 90 | user, 91 | }); 92 | 93 | const { token, refreshToken, tokenExpires } = await this.getTokensData({ 94 | id: user.id, 95 | sessionId: session.id, 96 | }); 97 | 98 | return { 99 | refreshToken, 100 | token, 101 | tokenExpires, 102 | user, 103 | }; 104 | } 105 | async validateSocialLogin( 106 | authProvider: AuthProvidersEnum, 107 | socialData: SocialType, 108 | ): Promise { 109 | let user: NullableType; 110 | const socialEmail = socialData.email?.toLowerCase(); 111 | 112 | const userByEmail = await this.usersService.findOneUser({ 113 | email: socialEmail, 114 | }); 115 | 116 | user = await this.usersService.findOneUser({ 117 | socialId: socialData.id, 118 | provider: authProvider, 119 | }); 120 | 121 | if (user) { 122 | if (socialEmail && !userByEmail) { 123 | user.email = socialEmail; 124 | } 125 | await this.usersService.saveUser(user); 126 | } else if (userByEmail) { 127 | user = userByEmail; 128 | } else { 129 | user = await this.usersService.createUser({ 130 | email: socialEmail ?? null, 131 | firstName: socialData.firstName ?? null, 132 | lastName: socialData.lastName ?? null, 133 | socialId: socialData.id, 134 | provider: authProvider, 135 | status: UserStatus.Active, 136 | }); 137 | 138 | user = await this.usersService.findOneUser({ 139 | id: user.id, 140 | }); 141 | } 142 | 143 | if (!user) { 144 | throw new HttpException( 145 | { 146 | status: HttpStatus.UNPROCESSABLE_ENTITY, 147 | errors: { 148 | user: 'userNotFound', 149 | }, 150 | }, 151 | HttpStatus.UNPROCESSABLE_ENTITY, 152 | ); 153 | } 154 | 155 | const session = await this.sessionService.create({ 156 | user, 157 | }); 158 | 159 | const { 160 | token: jwtToken, 161 | refreshToken, 162 | tokenExpires, 163 | } = await this.getTokensData({ 164 | id: user.id, 165 | sessionId: session.id, 166 | }); 167 | 168 | return { 169 | refreshToken, 170 | token: jwtToken, 171 | tokenExpires, 172 | user, 173 | }; 174 | } 175 | 176 | async registerUser(registerDto: AuthRegisterDto): Promise { 177 | const hash = crypto 178 | .createHash('sha256') 179 | .update(randomStringGenerator()) 180 | .digest('hex'); 181 | 182 | await this.usersService.createUser({ 183 | ...registerDto, 184 | email: registerDto.email, 185 | status: UserStatus.Inactive, 186 | hash, 187 | }); 188 | 189 | await this.mailsService.confirmRegisterUser({ 190 | to: registerDto.email, 191 | data: { 192 | hash, 193 | user: registerDto.firstName, 194 | }, 195 | }); 196 | } 197 | 198 | async status(userJwtPayload: JwtPayloadType): Promise> { 199 | return await this.usersService.findOneUser({ 200 | id: userJwtPayload.id, 201 | }); 202 | } 203 | 204 | async confirmEmail(hash: string): Promise { 205 | const user = await this.usersService.findOneUser({ 206 | hash, 207 | }); 208 | 209 | if (!user) { 210 | throw new HttpException( 211 | { 212 | status: HttpStatus.NOT_FOUND, 213 | error: `notFound`, 214 | }, 215 | HttpStatus.NOT_FOUND, 216 | ); 217 | } 218 | 219 | user.hash = null; 220 | user.status = UserStatus.Active; 221 | await this.usersService.saveUser(user); 222 | } 223 | 224 | async forgotPassword(email: string): Promise { 225 | const user = await this.usersService.findOneUser({ 226 | email, 227 | }); 228 | 229 | if (!user) { 230 | throw new HttpException( 231 | { 232 | status: HttpStatus.UNPROCESSABLE_ENTITY, 233 | errors: { 234 | email: 'emailNotExists', 235 | }, 236 | }, 237 | HttpStatus.UNPROCESSABLE_ENTITY, 238 | ); 239 | } 240 | 241 | const hash = crypto 242 | .createHash('sha256') 243 | .update(randomStringGenerator()) 244 | .digest('hex'); 245 | await this.forgotPasswordService.create({ 246 | hash, 247 | user, 248 | }); 249 | 250 | await this.mailsService.forgotPassword({ 251 | to: email, 252 | data: { 253 | hash, 254 | user: user.firstName, 255 | }, 256 | }); 257 | } 258 | 259 | async resetPassword(hash: string, password: string): Promise { 260 | const forgotReq = await this.forgotPasswordService.findOne({ 261 | where: { 262 | hash, 263 | }, 264 | }); 265 | 266 | if (!forgotReq) { 267 | throw new HttpException( 268 | { 269 | status: HttpStatus.UNPROCESSABLE_ENTITY, 270 | errors: { 271 | hash: `notFound`, 272 | }, 273 | }, 274 | HttpStatus.UNPROCESSABLE_ENTITY, 275 | ); 276 | } 277 | 278 | const user = forgotReq.user; 279 | user.password = password; 280 | 281 | await this.sessionService.softDelete({ 282 | user: { 283 | id: user.id, 284 | }, 285 | }); 286 | await this.usersService.saveUser(user); 287 | await this.forgotPasswordService.softDelete(forgotReq.id); 288 | } 289 | 290 | async refreshToken( 291 | data: Pick, 292 | ): Promise> { 293 | const session = await this.sessionService.findOne({ 294 | where: { 295 | id: data.sessionId, 296 | }, 297 | }); 298 | 299 | if (!session) { 300 | throw new UnauthorizedException(); 301 | } 302 | 303 | const { token, refreshToken, tokenExpires } = await this.getTokensData({ 304 | id: session.user.id, 305 | sessionId: session.id, 306 | }); 307 | 308 | return { 309 | token, 310 | refreshToken, 311 | tokenExpires, 312 | }; 313 | } 314 | 315 | async logout(data: Pick) { 316 | return this.sessionService.softDelete({ 317 | id: data.sessionId, 318 | }); 319 | } 320 | 321 | private async getTokensData(data: { 322 | id: User['id']; 323 | sessionId: Session['id']; 324 | }) { 325 | const tokenExpiresIn = this.configService.getOrThrow( 326 | 'auth.expires', 327 | { 328 | infer: true, 329 | }, 330 | ); 331 | 332 | const tokenExpires = Date.now() + ms(tokenExpiresIn); 333 | 334 | const [token, refreshToken] = await Promise.all([ 335 | await this.jwtService.signAsync( 336 | { 337 | id: data.id, 338 | sessionId: data.sessionId, 339 | }, 340 | { 341 | secret: this.configService.getOrThrow('auth.secret', { 342 | infer: true, 343 | }), 344 | expiresIn: tokenExpiresIn, 345 | }, 346 | ), 347 | await this.jwtService.signAsync( 348 | { 349 | sessionId: data.sessionId, 350 | }, 351 | { 352 | secret: this.configService.getOrThrow('auth.refreshSecret', { 353 | infer: true, 354 | }), 355 | expiresIn: this.configService.getOrThrow( 356 | 'auth.refreshExpires', 357 | { 358 | infer: true, 359 | }, 360 | ), 361 | }, 362 | ), 363 | ]); 364 | 365 | return { 366 | token, 367 | refreshToken, 368 | tokenExpires, 369 | }; 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /src/auth/auth.ts: -------------------------------------------------------------------------------- 1 | import { NullableType } from 'src/utils/types/nullable.type'; 2 | import { AuthEmailLoginDto } from './dtos/auth-email-login.dto'; 3 | import { AuthRegisterDto } from './dtos/auth-register.dto'; 4 | import { LoginResponseType } from './types/login-response.type'; 5 | import { User } from 'src/users/entities/user.entity'; 6 | import { JwtPayloadType } from './strategies/types/jwt-payload.type'; 7 | import { AuthProvidersEnum } from './enums/auth-providers.enum'; 8 | import { SocialType } from 'src/social/types/social.type'; 9 | import { JwtRefreshPayloadType } from './strategies/types/jwt-refresh-payload.type'; 10 | 11 | export interface IAuthService { 12 | validateLogin(loginDto: AuthEmailLoginDto): Promise; 13 | registerUser(registerDto: AuthRegisterDto): Promise; 14 | status(userJwtPayload: JwtPayloadType): Promise>; 15 | confirmEmail(hash: string): Promise; 16 | forgotPassword(email: string): Promise; 17 | resetPassword(hash: string, password: string): Promise; 18 | validateSocialLogin( 19 | authProvider: AuthProvidersEnum, 20 | socialData: SocialType, 21 | ): Promise; 22 | refreshToken( 23 | data: Pick, 24 | ): Promise>; 25 | 26 | logout(data: Pick); 27 | } 28 | -------------------------------------------------------------------------------- /src/auth/dtos/auth-confirm-email.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty } from 'class-validator'; 3 | 4 | export class AuthConfirmEmailDto { 5 | @ApiProperty() 6 | @IsNotEmpty() 7 | hash: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/dtos/auth-email-login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { Transform } from 'class-transformer'; 3 | import { lowerCaseTransformer } from 'src/utils/transformers/lower-case.transformer'; 4 | import { ApiProperty } from '@nestjs/swagger'; 5 | 6 | export class AuthEmailLoginDto { 7 | @ApiProperty({ example: 'ramez@gmail.com' }) 8 | @Transform(lowerCaseTransformer) 9 | email: string; 10 | 11 | @ApiProperty() 12 | @IsNotEmpty() 13 | password: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/auth/dtos/auth-forgot-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail } from 'class-validator'; 2 | import { Transform } from 'class-transformer'; 3 | import { lowerCaseTransformer } from 'src/utils/transformers/lower-case.transformer'; 4 | import { ApiProperty } from '@nestjs/swagger'; 5 | 6 | export class AuthForgotPasswordDto { 7 | @ApiProperty() 8 | @Transform(lowerCaseTransformer) 9 | @IsEmail() 10 | email: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/dtos/auth-register.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; 2 | import { Transform } from 'class-transformer'; 3 | import { lowerCaseTransformer } from 'src/utils/transformers/lower-case.transformer'; 4 | import { ApiProperty } from '@nestjs/swagger'; 5 | 6 | export class AuthRegisterDto { 7 | @ApiProperty({ example: 'ramez@gmail.com' }) 8 | @Transform(lowerCaseTransformer) 9 | @IsEmail() 10 | email: string; 11 | 12 | @ApiProperty({ example: '123456' }) 13 | @MinLength(6) 14 | password: string; 15 | 16 | @ApiProperty({ example: 'Ramez' }) 17 | @IsNotEmpty() 18 | firstName: string; 19 | 20 | @ApiProperty({ example: 'Ben Taher' }) 21 | @IsNotEmpty() 22 | lastName: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/auth/dtos/auth-reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class AuthResetPasswordDto { 5 | @ApiProperty() 6 | @IsNotEmpty() 7 | password: string; 8 | 9 | @ApiProperty() 10 | @IsNotEmpty() 11 | hash: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/auth/enums/auth-providers.enum.ts: -------------------------------------------------------------------------------- 1 | export enum AuthProvidersEnum { 2 | email = 'email', 3 | facebook = 'facebook', 4 | google = 'google', 5 | twitter = 'twitter', 6 | apple = 'apple', 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/strategies/anonymous.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-anonymous'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class AnonymousStrategy extends PassportStrategy(Strategy) { 7 | constructor() { 8 | super(); 9 | } 10 | 11 | public validate(payload: unknown, request: unknown): unknown { 12 | return request; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt-refresh.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { JwtRefreshPayloadType } from './types/jwt-refresh-payload.type'; 6 | import { OrNeverType } from '../../utils/types/or-never.type'; 7 | import { AllConfigType } from 'src/config/config.type'; 8 | 9 | @Injectable() 10 | export class JwtRefreshStrategy extends PassportStrategy( 11 | Strategy, 12 | 'jwt-refresh', 13 | ) { 14 | constructor(private configService: ConfigService) { 15 | super({ 16 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 17 | secretOrKey: configService.get('auth.refreshSecret', { 18 | infer: true, 19 | }), 20 | }); 21 | } 22 | 23 | public validate( 24 | payload: JwtRefreshPayloadType, 25 | ): OrNeverType { 26 | if (!payload.sessionId) { 27 | throw new UnauthorizedException(); 28 | } 29 | 30 | return payload; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { OrNeverType } from '../../utils/types/or-never.type'; 6 | import { AllConfigType } from 'src/config/config.type'; 7 | import { JwtPayloadType } from './types/jwt-payload.type'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 11 | constructor(private configService: ConfigService) { 12 | super({ 13 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 14 | ignoreExpiration: false, 15 | secretOrKey: configService.get('auth.secret', { 16 | infer: true, 17 | }), 18 | }); 19 | } 20 | 21 | public validate(payload: JwtPayloadType): OrNeverType { 22 | if (!payload.id) { 23 | throw new UnauthorizedException(); 24 | } 25 | 26 | return payload; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/auth/strategies/types/jwt-payload.type.ts: -------------------------------------------------------------------------------- 1 | import { Session } from 'src/session/entities/session.entity'; 2 | import { User } from '../../../users/entities/user.entity'; 3 | 4 | export type JwtPayloadType = Pick & { 5 | sessionId: Session['id']; 6 | iat: number; 7 | exp: number; 8 | }; 9 | -------------------------------------------------------------------------------- /src/auth/strategies/types/jwt-refresh-payload.type.ts: -------------------------------------------------------------------------------- 1 | import { Session } from 'src/session/entities/session.entity'; 2 | 3 | export type JwtRefreshPayloadType = { 4 | sessionId: Session['id']; 5 | iat: number; 6 | exp: number; 7 | }; 8 | -------------------------------------------------------------------------------- /src/auth/tests/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from '../auth.controller'; 3 | 4 | describe('AuthController', () => { 5 | let controller: AuthController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AuthController], 10 | }).compile(); 11 | 12 | controller = module.get(AuthController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/tests/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from '../auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/types/login-response.type.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../users/entities/user.entity'; 2 | 3 | export type LoginResponseType = Readonly<{ 4 | token: string; 5 | refreshToken: string; 6 | tokenExpires: number; 7 | user: User; 8 | }>; 9 | -------------------------------------------------------------------------------- /src/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { AppConfig } from './config.type'; 3 | import validateConfig from 'src/utils/validate-config'; 4 | import { 5 | IsEnum, 6 | IsInt, 7 | IsOptional, 8 | IsString, 9 | IsUrl, 10 | Max, 11 | Min, 12 | } from 'class-validator'; 13 | 14 | enum Environment { 15 | Development = 'development', 16 | Production = 'production', 17 | Test = 'test', 18 | } 19 | 20 | class EnvironmentVariablesValidator { 21 | @IsEnum(Environment) 22 | @IsOptional() 23 | NODE_ENV: Environment; 24 | 25 | @IsInt() 26 | @Min(0) 27 | @Max(65535) 28 | @IsOptional() 29 | APP_PORT: number; 30 | 31 | @IsUrl({ require_tld: false }) 32 | @IsOptional() 33 | FRONTEND_DOMAIN: string; 34 | 35 | @IsUrl({ require_tld: false }) 36 | @IsOptional() 37 | BACKEND_DOMAIN: string; 38 | 39 | @IsString() 40 | @IsOptional() 41 | API_PREFIX: string; 42 | } 43 | 44 | export default registerAs('app', () => { 45 | validateConfig(process.env, EnvironmentVariablesValidator); 46 | 47 | return { 48 | nodeEnv: process.env.NODE_ENV || 'development', 49 | name: process.env.APP_NAME || 'app', 50 | workingDirectory: process.env.PWD || process.cwd(), 51 | frontendDomain: process.env.FRONTEND_DOMAIN, 52 | backendDomain: process.env.BACKEND_DOMAIN ?? 'http://localhost', 53 | port: process.env.APP_PORT 54 | ? parseInt(process.env.APP_PORT, 10) 55 | : process.env.PORT 56 | ? parseInt(process.env.PORT, 10) 57 | : 3000, 58 | apiPrefix: process.env.API_PREFIX || 'api', 59 | }; 60 | }); 61 | -------------------------------------------------------------------------------- /src/config/auth.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { AuthConfig } from './config.type'; 3 | import { IsString } from 'class-validator'; 4 | import validateConfig from 'src/utils/validate-config'; 5 | 6 | class EnvironmentVariablesValidator { 7 | @IsString() 8 | AUTH_JWT_SECRET: string; 9 | 10 | @IsString() 11 | AUTH_JWT_TOKEN_EXPIRES_IN: string; 12 | 13 | @IsString() 14 | AUTH_REFRESH_SECRET: string; 15 | 16 | @IsString() 17 | AUTH_REFRESH_TOKEN_EXPIRES_IN: string; 18 | } 19 | 20 | export default registerAs('auth', () => { 21 | validateConfig(process.env, EnvironmentVariablesValidator); 22 | 23 | return { 24 | secret: process.env.AUTH_JWT_SECRET, 25 | expires: process.env.AUTH_JWT_TOKEN_EXPIRES_IN, 26 | refreshSecret: process.env.AUTH_REFRESH_SECRET, 27 | refreshExpires: process.env.AUTH_REFRESH_TOKEN_EXPIRES_IN, 28 | }; 29 | }); 30 | -------------------------------------------------------------------------------- /src/config/config.type.ts: -------------------------------------------------------------------------------- 1 | export type AppConfig = { 2 | nodeEnv: string; 3 | name: string; 4 | workingDirectory: string; 5 | frontendDomain?: string; 6 | backendDomain: string; 7 | port: number; 8 | apiPrefix: string; 9 | }; 10 | 11 | export type DatabaseConfig = { 12 | url?: string; 13 | type?: string; 14 | host?: string; 15 | port?: number; 16 | password?: string; 17 | name?: string; 18 | username?: string; 19 | synchronize?: boolean; 20 | }; 21 | 22 | export type AuthConfig = { 23 | secret?: string; 24 | expires?: string; 25 | refreshSecret?: string; 26 | refreshExpires?: string; 27 | }; 28 | 29 | export type MailerConfig = { 30 | port: number; 31 | host?: string; 32 | user?: string; 33 | password?: string; 34 | defaultEmail?: string; 35 | defaultName?: string; 36 | ignoreTLS: boolean; 37 | secure: boolean; 38 | requireTLS: boolean; 39 | }; 40 | 41 | export type GoogleConfig = { 42 | clientId?: string; 43 | clientSecret?: string; 44 | }; 45 | 46 | export type AllConfigType = { 47 | app: AppConfig; 48 | database: DatabaseConfig; 49 | auth: AuthConfig; 50 | mailer: MailerConfig; 51 | google: GoogleConfig; 52 | }; 53 | -------------------------------------------------------------------------------- /src/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { DatabaseConfig } from './config.type'; 3 | import { 4 | IsOptional, 5 | IsInt, 6 | Min, 7 | Max, 8 | IsString, 9 | ValidateIf, 10 | IsBoolean, 11 | } from 'class-validator'; 12 | import validateConfig from 'src/utils/validate-config'; 13 | 14 | class EnvironmentVariablesValidator { 15 | @ValidateIf((envValues) => envValues.DATABASE_URL) 16 | @IsString() 17 | DATABASE_URL: string; 18 | 19 | @ValidateIf((envValues) => !envValues.DATABASE_URL) 20 | @IsString() 21 | DATABASE_TYPE: string; 22 | 23 | @ValidateIf((envValues) => !envValues.DATABASE_URL) 24 | @IsString() 25 | DATABASE_HOST: string; 26 | 27 | @ValidateIf((envValues) => !envValues.DATABASE_URL) 28 | @IsInt() 29 | @Min(0) 30 | @Max(65535) 31 | @IsOptional() 32 | DATABASE_PORT: number; 33 | 34 | @ValidateIf((envValues) => !envValues.DATABASE_URL) 35 | @IsString() 36 | @IsOptional() 37 | DATABASE_PASSWORD: string; 38 | 39 | @ValidateIf((envValues) => !envValues.DATABASE_URL) 40 | @IsString() 41 | DATABASE_NAME: string; 42 | 43 | @ValidateIf((envValues) => !envValues.DATABASE_URL) 44 | @IsString() 45 | DATABASE_USERNAME: string; 46 | 47 | @IsBoolean() 48 | @IsOptional() 49 | DATABASE_SYNCHRONIZE: boolean; 50 | } 51 | 52 | export default registerAs('database', () => { 53 | validateConfig(process.env, EnvironmentVariablesValidator); 54 | 55 | return { 56 | url: process.env.DATABASE_URL, 57 | type: process.env.DATABASE_TYPE, 58 | host: process.env.DATABASE_HOST, 59 | port: process.env.DATABASE_PORT 60 | ? parseInt(process.env.DATABASE_PORT, 10) 61 | : 5432, 62 | password: process.env.DATABASE_PASSWORD, 63 | name: process.env.DATABASE_NAME, 64 | username: process.env.DATABASE_USERNAME, 65 | synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', 66 | }; 67 | }); 68 | -------------------------------------------------------------------------------- /src/config/google.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { GoogleConfig } from './config.type'; 3 | import { IsOptional, IsString } from 'class-validator'; 4 | import validateConfig from 'src/utils/validate-config'; 5 | 6 | class EnvironmentVariablesValidator { 7 | @IsString() 8 | @IsOptional() 9 | GOOGLE_CLIENT_ID: string; 10 | 11 | @IsString() 12 | @IsOptional() 13 | GOOGLE_CLIENT_SECRET: string; 14 | 15 | @IsString() 16 | @IsOptional() 17 | CALL_BACK_URL: string; 18 | } 19 | 20 | export default registerAs('google', () => { 21 | validateConfig(process.env, EnvironmentVariablesValidator); 22 | 23 | return { 24 | clientId: process.env.GOOGLE_CLIENT_ID, 25 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 26 | callbackURL: process.env.CALL_BACK_URL, 27 | }; 28 | }); 29 | -------------------------------------------------------------------------------- /src/config/mailer.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { MailerConfig } from './config.type'; 3 | import { 4 | IsString, 5 | IsInt, 6 | Min, 7 | Max, 8 | IsOptional, 9 | IsBoolean, 10 | IsEmail, 11 | } from 'class-validator'; 12 | import validateConfig from 'src/utils/validate-config'; 13 | 14 | class EnvironmentVariablesValidator { 15 | @IsInt() 16 | @Min(0) 17 | @Max(65535) 18 | @IsOptional() 19 | MAILER_PORT: number; 20 | 21 | @IsString() 22 | MAILER_HOST: string; 23 | 24 | @IsString() 25 | @IsOptional() 26 | MAILER_USER: string; 27 | 28 | @IsString() 29 | @IsOptional() 30 | MAILER_PASSWORD: string; 31 | 32 | @IsEmail() 33 | MAILER_DEFAULT_EMAIL: string; 34 | 35 | @IsString() 36 | MAILER_DEFAULT_NAME: string; 37 | 38 | @IsBoolean() 39 | MAILER_IGNORE_TLS: boolean; 40 | 41 | @IsBoolean() 42 | MAILER_SECURE: boolean; 43 | 44 | @IsBoolean() 45 | MAILER_REQUIRE_TLS: boolean; 46 | } 47 | 48 | export default registerAs('mailer', () => { 49 | validateConfig(process.env, EnvironmentVariablesValidator); 50 | 51 | return { 52 | port: process.env.MAILER_PORT ? parseInt(process.env.MAILER_PORT, 10) : 587, 53 | host: process.env.MAILER_HOST, 54 | user: process.env.MAILER_USER, 55 | password: process.env.MAILER_PASSWORD, 56 | defaultEmail: process.env.MAILER_DEFAULT_EMAIL, 57 | defaultName: process.env.MAILER_DEFAULT_NAME, 58 | ignoreTLS: process.env.MAILER_IGNORE_TLS === 'true', 59 | secure: process.env.MAILER_SECURE === 'true', 60 | requireTLS: process.env.MAILER_REQUIRE_TLS === 'true', 61 | }; 62 | }); 63 | -------------------------------------------------------------------------------- /src/database/data-source.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { DataSource, DataSourceOptions } from 'typeorm'; 3 | 4 | export const AppDataSource = new DataSource({ 5 | type: process.env.DATABASE_TYPE, 6 | url: process.env.DATABASE_URL, 7 | host: process.env.DATABASE_HOST, 8 | port: process.env.DATABASE_PORT 9 | ? parseInt(process.env.DATABASE_PORT, 10) 10 | : 5432, 11 | username: process.env.DATABASE_USERNAME, 12 | password: process.env.DATABASE_PASSWORD, 13 | database: process.env.DATABASE_NAME, 14 | synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', 15 | dropSchema: false, 16 | keepConnectionAlive: true, 17 | logging: process.env.NODE_ENV !== 'production', 18 | entities: [__dirname + '/../**/*.entity{.ts,.js}'], 19 | } as DataSourceOptions); 20 | -------------------------------------------------------------------------------- /src/database/typeorm-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; 4 | import { AllConfigType } from 'src/config/config.type'; 5 | 6 | @Injectable() 7 | export class TypeOrmConfigService implements TypeOrmOptionsFactory { 8 | constructor(private configService: ConfigService) {} 9 | 10 | createTypeOrmOptions(): TypeOrmModuleOptions { 11 | return { 12 | type: this.configService.get('database.type', { infer: true }), 13 | url: this.configService.get('database.url', { infer: true }), 14 | host: this.configService.get('database.host', { infer: true }), 15 | port: this.configService.get('database.port', { infer: true }), 16 | username: this.configService.get('database.username', { 17 | infer: true, 18 | }), 19 | password: this.configService.get('database.password', { 20 | infer: true, 21 | }), 22 | database: this.configService.get('database.name', { 23 | infer: true, 24 | }), 25 | synchronize: this.configService.get('database.synchronize', { 26 | infer: true, 27 | }), 28 | dropSchema: false, 29 | keepConnectionAlive: true, 30 | logging: 31 | this.configService.get('app.nodeEnv', { infer: true }) !== 32 | 'production', 33 | entities: [__dirname + '/../**/*.entity{.ts,.js}'], 34 | } as TypeOrmModuleOptions; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/forgot-password/entities/forgot-password.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | Index, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | DeleteDateColumn, 9 | } from 'typeorm'; 10 | import { User } from '../../users/entities/user.entity'; 11 | import { Allow } from 'class-validator'; 12 | import { EntityHelper } from 'src/utils/entity-helper'; 13 | 14 | @Entity() 15 | export class ForgotPassword extends EntityHelper { 16 | @PrimaryGeneratedColumn() 17 | id: number; 18 | 19 | @Allow() 20 | @Column() 21 | @Index() 22 | hash: string; 23 | 24 | @Allow() 25 | @ManyToOne(() => User, { 26 | eager: true, 27 | }) 28 | user: User; 29 | 30 | @CreateDateColumn() 31 | createdAt: Date; 32 | 33 | @DeleteDateColumn() 34 | deletedAt: Date; 35 | } 36 | -------------------------------------------------------------------------------- /src/forgot-password/forgot-password.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ForgotPasswordService } from './forgot-password.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { ForgotPassword } from './entities/forgot-password.entity'; 5 | import { Services } from 'src/utils/constants'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([ForgotPassword])], 9 | providers: [ 10 | { 11 | provide: Services.FORGOT_PASSWORD, 12 | useClass: ForgotPasswordService, 13 | }, 14 | ], 15 | exports: [ 16 | { 17 | provide: Services.FORGOT_PASSWORD, 18 | useClass: ForgotPasswordService, 19 | }, 20 | ], 21 | }) 22 | export class ForgotPasswordModule {} 23 | -------------------------------------------------------------------------------- /src/forgot-password/forgot-password.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { IForgotPasswordService } from './forgot-password'; 3 | import { FindOptions } from 'src/utils/types/find-options.type'; 4 | import { ForgotPassword } from './entities/forgot-password.entity'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | import { DeepPartial, Repository } from 'typeorm'; 7 | 8 | @Injectable() 9 | export class ForgotPasswordService implements IForgotPasswordService { 10 | constructor( 11 | @InjectRepository(ForgotPassword) 12 | private readonly forgotPasswordRepository: Repository, 13 | ) {} 14 | 15 | async create(data: DeepPartial): Promise { 16 | const forgotPasswordReq = this.forgotPasswordRepository.create(data); 17 | return this.forgotPasswordRepository.save(forgotPasswordReq); 18 | } 19 | 20 | findOne(options: FindOptions): Promise { 21 | return this.forgotPasswordRepository.findOne({ 22 | where: options.where, 23 | }); 24 | } 25 | 26 | async softDelete(id: ForgotPassword['id']): Promise { 27 | await this.forgotPasswordRepository.softDelete(id); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/forgot-password/forgot-password.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from 'typeorm'; 2 | import { ForgotPassword } from './entities/forgot-password.entity'; 3 | import { FindOptions } from 'src/utils/types/find-options.type'; 4 | import { NullableType } from 'src/utils/types/nullable.type'; 5 | 6 | export interface IForgotPasswordService { 7 | create(data: DeepPartial): Promise; 8 | softDelete(id: ForgotPassword['id']): Promise; 9 | findOne( 10 | options: FindOptions, 11 | ): Promise>; 12 | } 13 | -------------------------------------------------------------------------------- /src/forgot-password/tests/forgot-password.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ForgotPasswordService } from '../forgot-password.service'; 3 | 4 | describe('ForgotPasswordService', () => { 5 | let service: ForgotPasswordService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ForgotPasswordService], 10 | }).compile(); 11 | 12 | service = module.get(ForgotPasswordService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/mailer/mailer.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MailerService } from './mailer.service'; 3 | import { Services } from 'src/utils/constants'; 4 | 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: Services.MAILER, 9 | useClass: MailerService, 10 | }, 11 | ], 12 | exports: [ 13 | { 14 | provide: Services.MAILER, 15 | useClass: MailerService, 16 | }, 17 | ], 18 | }) 19 | export class MailerModule {} 20 | -------------------------------------------------------------------------------- /src/mailer/mailer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import nodemailer from 'nodemailer'; 4 | import { AllConfigType } from 'src/config/config.type'; 5 | import Handlebars from 'handlebars'; 6 | import fs from 'node:fs/promises'; 7 | import { IMailerService } from './mailer'; 8 | 9 | @Injectable() 10 | export class MailerService implements IMailerService { 11 | private readonly transporter: nodemailer.Transporter; 12 | constructor(private readonly configService: ConfigService) { 13 | this.transporter = nodemailer.createTransport({ 14 | host: configService.get('mailer.host', { infer: true }), 15 | port: configService.get('mailer.port', { infer: true }), 16 | ignoreTLS: configService.get('mailer.ignoreTLS', { 17 | infer: true, 18 | }), 19 | secure: configService.get('mailer.secure', { infer: true }), 20 | requireTLS: configService.get('mailer.requireTLS', { 21 | infer: true, 22 | }), 23 | auth: { 24 | user: configService.get('mailer.user', { infer: true }), 25 | pass: configService.get('mailer.password', { infer: true }), 26 | }, 27 | 28 | debug: true, 29 | }); 30 | } 31 | 32 | async sendMail({ 33 | templatePath, 34 | context, 35 | ...mailOptions 36 | }: nodemailer.SendMailOptions & { 37 | templatePath: string; 38 | context: Record; 39 | }): Promise { 40 | let html: string | undefined; 41 | if (templatePath) { 42 | const template = await fs.readFile(templatePath, 'utf-8'); 43 | html = Handlebars.compile(template, { 44 | strict: true, 45 | })(context); 46 | } 47 | 48 | await this.transporter.sendMail({ 49 | ...mailOptions, 50 | from: mailOptions.from 51 | ? mailOptions.from 52 | : `"${this.configService.get('mailer.defaultName', { 53 | infer: true, 54 | })}" <${this.configService.get('mailer.defaultEmail', { 55 | infer: true, 56 | })}>`, 57 | html: mailOptions.html ? mailOptions.html : html, 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/mailer/mailer.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | 3 | export interface IMailerService { 4 | sendMail({ 5 | templatePath, 6 | context, 7 | ...mailOptions 8 | }: nodemailer.SendMailOptions & { 9 | templatePath: string; 10 | context: Record; 11 | }): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/mailer/tests/mailer.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MailerService } from '../mailer.service'; 3 | 4 | describe('MailerService', () => { 5 | let service: MailerService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [MailerService], 10 | }).compile(); 11 | 12 | service = module.get(MailerService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/mails/mails.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MailsService } from './mails.service'; 3 | import { Services } from 'src/utils/constants'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { MailerModule } from 'src/mailer/mailer.module'; 6 | 7 | @Module({ 8 | imports: [ConfigModule, MailerModule], 9 | providers: [ 10 | { 11 | provide: Services.MAILS, 12 | useClass: MailsService, 13 | }, 14 | ], 15 | exports: [ 16 | { 17 | provide: Services.MAILS, 18 | useClass: MailsService, 19 | }, 20 | ], 21 | }) 22 | export class MailsModule {} 23 | -------------------------------------------------------------------------------- /src/mails/mails.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { Services } from 'src/utils/constants'; 3 | import { IMailerService } from 'src/mailer/mailer'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { AllConfigType } from 'src/config/config.type'; 6 | import { IMailsService } from './mails'; 7 | import { MailData } from './types/mails.type'; 8 | import path from 'path'; 9 | 10 | @Injectable() 11 | export class MailsService implements IMailsService { 12 | constructor( 13 | @Inject(Services.MAILER) private readonly mailerService: IMailerService, 14 | 15 | private readonly configService: ConfigService, 16 | ) {} 17 | 18 | async confirmRegisterUser( 19 | mailData: MailData<{ hash: string; user: string }>, 20 | ): Promise { 21 | await this.mailerService.sendMail({ 22 | to: mailData.to, 23 | subject: 'Email Confirmation', 24 | text: `${this.configService.get('app.frontendDomain', { 25 | infer: true, 26 | })}/confirm-email/${mailData.data.hash}`, 27 | templatePath: path.join( 28 | this.configService.getOrThrow('app.workingDirectory', { 29 | infer: true, 30 | }), 31 | 'src', 32 | 'mails', 33 | 'templates', 34 | 'confirm.hbs', 35 | ), 36 | context: { 37 | username: mailData.data.user, 38 | confirmationLink: `${this.configService.get( 39 | 'app.frontendDomain', 40 | { 41 | infer: true, 42 | }, 43 | )}/confirm-email/${mailData.data.hash}`, 44 | }, 45 | }); 46 | } 47 | 48 | async forgotPassword( 49 | mailData: MailData<{ hash: string; user: string }>, 50 | ): Promise { 51 | await this.mailerService.sendMail({ 52 | to: mailData.to, 53 | subject: 'Password Reset', 54 | text: `${this.configService.get('app.frontendDomain', { 55 | infer: true, 56 | })}/password-change/${mailData.data.hash}`, 57 | templatePath: path.join( 58 | this.configService.getOrThrow('app.workingDirectory', { 59 | infer: true, 60 | }), 61 | 'src', 62 | 'mails', 63 | 'templates', 64 | 'reset-password.hbs', 65 | ), 66 | context: { 67 | username: mailData.data.user, 68 | resetLink: `${this.configService.get('app.frontendDomain', { 69 | infer: true, 70 | })}/password-change/${mailData.data.hash}`, 71 | }, 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/mails/mails.ts: -------------------------------------------------------------------------------- 1 | import { MailData } from './types/mails.type'; 2 | 3 | export interface IMailsService { 4 | confirmRegisterUser( 5 | mailData: MailData<{ hash: string; user: string }>, 6 | ): Promise; 7 | 8 | forgotPassword( 9 | mailData: MailData<{ hash: string; user: string }>, 10 | ): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/mails/templates/confirm.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Email Confirmation 5 | 6 | 7 | 14 | 15 | 18 | 19 | 20 | 46 | 47 | 48 | 51 | 52 |
16 |

Email Confirmation

17 |
21 |

Dear {{username}},

22 |

Thank you for signing up! Please confirm your email address by 23 | clicking the button below:

24 | 25 | 26 | 42 | 43 |
27 | 28 | 29 | 39 | 40 |
34 | Confirm Email 38 |
41 |
44 |

If you didn't sign up for our service, please ignore this email.

45 |
49 |

© 2023 Your Company. All rights reserved.

50 |
53 | 54 | -------------------------------------------------------------------------------- /src/mails/templates/reset-password.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Password Reset 5 | 6 | 7 | 14 | 15 | 18 | 19 | 20 | 46 | 47 | 48 | 51 | 52 |
16 |

Password Reset

17 |
21 |

Hello {{username}},

22 |

We received a request to reset your password. Click the button 23 | below to set a new password:

24 | 25 | 26 | 42 | 43 |
27 | 28 | 29 | 39 | 40 |
34 | Reset Password 38 |
41 |
44 |

If you didn't request a password reset, please ignore this email.

45 |
49 |

© 2023 Your Company. All rights reserved.

50 |
53 | 54 | -------------------------------------------------------------------------------- /src/mails/tests/mails.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MailsService } from '../mails.service'; 3 | 4 | describe('MailsService', () => { 5 | let service: MailsService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [MailsService], 10 | }).compile(); 11 | 12 | service = module.get(MailsService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/mails/types/mails.type.ts: -------------------------------------------------------------------------------- 1 | export type MailData = { 2 | to: string; 3 | data: T; 4 | }; 5 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { AllConfigType } from './config/config.type'; 5 | 6 | import { ValidationPipe } from '@nestjs/common'; 7 | import validationOptions from './utils/validation-options'; 8 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule); 12 | app.enableCors({ 13 | origin: ['http://localhost:3000'], 14 | credentials: true, 15 | }); 16 | const configService = app.get(ConfigService); 17 | 18 | app.setGlobalPrefix( 19 | configService.getOrThrow('app.apiPrefix', { infer: true }), 20 | { 21 | exclude: ['/'], 22 | }, 23 | ); 24 | 25 | app.useGlobalPipes(new ValidationPipe(validationOptions)); 26 | 27 | const options = new DocumentBuilder() 28 | .setTitle('Nest Full Auth API') 29 | .setDescription( 30 | 'NestJS boilerplate. Auth, TypeORM, MySql, Mailing, Google OAuth20', 31 | ) 32 | .setVersion('1.0') 33 | .addBearerAuth() 34 | .build(); 35 | 36 | const document = SwaggerModule.createDocument(app, options); 37 | SwaggerModule.setup('docs', app, document); 38 | 39 | try { 40 | const PORT = configService.getOrThrow('app.port', { infer: true }); 41 | await app.listen(PORT, () => { 42 | console.log(`Running on Port ${PORT}`); 43 | console.log( 44 | `Running in ${configService.getOrThrow('app.nodeEnv', { 45 | infer: true, 46 | })} `, 47 | ); 48 | }); 49 | } catch (err) { 50 | console.log(err); 51 | } 52 | } 53 | 54 | void bootstrap(); 55 | -------------------------------------------------------------------------------- /src/session/entities/session.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | Entity, 4 | Index, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | DeleteDateColumn, 8 | } from 'typeorm'; 9 | import { User } from '../../users/entities/user.entity'; 10 | import { EntityHelper } from 'src/utils/entity-helper'; 11 | 12 | @Entity() 13 | export class Session extends EntityHelper { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @ManyToOne(() => User, { 18 | eager: true, 19 | }) 20 | @Index() 21 | user: User; 22 | 23 | @CreateDateColumn() 24 | createdAt: Date; 25 | 26 | @DeleteDateColumn() 27 | deletedAt: Date; 28 | } 29 | -------------------------------------------------------------------------------- /src/session/session.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SessionService } from './session.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Services } from 'src/utils/constants'; 5 | import { Session } from './entities/session.entity'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Session])], 9 | 10 | providers: [ 11 | { 12 | provide: Services.SESSION, 13 | useClass: SessionService, 14 | }, 15 | ], 16 | exports: [ 17 | { 18 | provide: Services.SESSION, 19 | useClass: SessionService, 20 | }, 21 | ], 22 | }) 23 | export class SessionModule {} 24 | -------------------------------------------------------------------------------- /src/session/session.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { DeepPartial, Not, Repository } from 'typeorm'; 4 | import { Session } from './entities/session.entity'; 5 | import { User } from 'src/users/entities/user.entity'; 6 | import { FindOptions } from 'src/utils/types/find-options.type'; 7 | import { NullableType } from 'src/utils/types/nullable.type'; 8 | import { ISessionService } from './session'; 9 | 10 | @Injectable() 11 | export class SessionService implements ISessionService { 12 | constructor( 13 | @InjectRepository(Session) 14 | private readonly sessionRepository: Repository, 15 | ) {} 16 | async findOne(options: FindOptions): Promise> { 17 | return this.sessionRepository.findOne({ 18 | where: options.where, 19 | }); 20 | } 21 | 22 | async findMany(options: FindOptions): Promise { 23 | return this.sessionRepository.find({ 24 | where: options.where, 25 | }); 26 | } 27 | 28 | async create(data: DeepPartial): Promise { 29 | const session = this.sessionRepository.create(data); 30 | return this.sessionRepository.save(session); 31 | } 32 | 33 | async softDelete({ 34 | excludeId, 35 | ...criteria 36 | }: { 37 | id?: Session['id']; 38 | user?: Pick; 39 | excludeId?: Session['id']; 40 | }): Promise { 41 | await this.sessionRepository.softDelete({ 42 | ...criteria, 43 | id: criteria.id ? criteria.id : excludeId ? Not(excludeId) : undefined, 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/session/session.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'src/users/entities/user.entity'; 2 | import { Session } from './entities/session.entity'; 3 | import { DeepPartial } from 'typeorm'; 4 | import { FindOptions } from 'src/utils/types/find-options.type'; 5 | import { NullableType } from 'src/utils/types/nullable.type'; 6 | 7 | export interface ISessionService { 8 | create(data: DeepPartial): Promise; 9 | findOne(options: FindOptions): Promise>; 10 | findMany(options: FindOptions): Promise; 11 | softDelete({ 12 | excludeId, 13 | ...criteria 14 | }: { 15 | id?: Session['id']; 16 | user?: Pick; 17 | excludeId?: Session['id']; 18 | }): Promise; 19 | } 20 | -------------------------------------------------------------------------------- /src/session/tests/session.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SessionService } from '../session.service'; 3 | 4 | describe('SessionService', () => { 5 | let service: SessionService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SessionService], 10 | }).compile(); 11 | 12 | service = module.get(SessionService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/social/types/social.type.ts: -------------------------------------------------------------------------------- 1 | export type SocialType = { 2 | id: string; 3 | firstName?: string; 4 | lastName?: string; 5 | email?: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/users/dtos/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | 3 | import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; 4 | 5 | import { lowerCaseTransformer } from 'src/utils/transformers/lower-case.transformer'; 6 | import { UserStatus } from '../entities/user.entity'; 7 | import { AuthProvidersEnum } from 'src/auth/enums/auth-providers.enum'; 8 | import { ApiProperty } from '@nestjs/swagger'; 9 | 10 | export class CreateUserDto { 11 | @ApiProperty({ example: 'ramez@gmail.com' }) 12 | @Transform(lowerCaseTransformer) 13 | @IsNotEmpty() 14 | @IsEmail() 15 | email: string | null; 16 | 17 | @ApiProperty({ example: '123456' }) 18 | @MinLength(6) 19 | password?: string; 20 | 21 | @ApiProperty({ example: 'Ramez' }) 22 | @IsNotEmpty() 23 | firstName: string | null; 24 | 25 | @ApiProperty({ example: 'Ben Taher' }) 26 | @IsNotEmpty() 27 | lastName: string | null; 28 | 29 | provider?: AuthProvidersEnum; 30 | status?: UserStatus; 31 | socialId?: string | null; 32 | 33 | hash?: string | null; 34 | } 35 | -------------------------------------------------------------------------------- /src/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | AfterLoad, 4 | CreateDateColumn, 5 | Entity, 6 | Index, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | BeforeInsert, 10 | BeforeUpdate, 11 | } from 'typeorm'; 12 | 13 | import { EntityHelper } from 'src/utils/entity-helper'; 14 | 15 | import { Exclude } from 'class-transformer'; 16 | import { AuthProvidersEnum } from 'src/auth/enums/auth-providers.enum'; 17 | import { hashPassword } from 'src/utils/helpers'; 18 | 19 | export enum UserStatus { 20 | Active = 'active', 21 | Inactive = 'inactive', 22 | } 23 | 24 | @Entity() 25 | export class User extends EntityHelper { 26 | @PrimaryGeneratedColumn() 27 | id: number; 28 | 29 | @Column({ type: String, unique: true, nullable: true }) 30 | email: string | null; 31 | 32 | @Column({ nullable: true }) 33 | @Exclude({ toPlainOnly: true }) 34 | password: string; 35 | 36 | @Exclude({ toPlainOnly: true }) 37 | public previousPassword: string; 38 | 39 | @AfterLoad() 40 | public loadPreviousPassword(): void { 41 | this.previousPassword = this.password; 42 | } 43 | 44 | @BeforeInsert() 45 | @BeforeUpdate() 46 | async setPassword() { 47 | if (this.previousPassword !== this.password && this.password) { 48 | this.password = await hashPassword(this.password); 49 | } 50 | } 51 | 52 | @Column({ default: AuthProvidersEnum.email }) 53 | provider: string; 54 | 55 | @Column({ type: 'enum', enum: UserStatus, default: UserStatus.Inactive }) 56 | status: UserStatus; 57 | 58 | @Index() 59 | @Column({ type: String, nullable: true }) 60 | socialId: string | null; 61 | 62 | @Index() 63 | @Column({ type: String, nullable: true }) 64 | firstName: string | null; 65 | 66 | @Index() 67 | @Column({ type: String, nullable: true }) 68 | lastName: string | null; 69 | 70 | @CreateDateColumn() 71 | createdAt: Date; 72 | 73 | @Column({ type: String, nullable: true }) 74 | @Index() 75 | @Exclude({ toPlainOnly: true }) 76 | hash: string | null; 77 | 78 | @UpdateDateColumn() 79 | updatedAt: Date; 80 | } 81 | -------------------------------------------------------------------------------- /src/users/tests/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersController } from '../users.controller'; 3 | 4 | describe('UsersController', () => { 5 | let controller: UsersController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UsersController], 10 | }).compile(); 11 | 12 | controller = module.get(UsersController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/users/tests/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersService } from '../users.service'; 3 | 4 | describe('UsersService', () => { 5 | let service: UsersService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UsersService], 10 | }).compile(); 11 | 12 | service = module.get(UsersService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | @Controller('users') 4 | export class UsersController {} 5 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './entities/user.entity'; 6 | import { Services } from 'src/utils/constants'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User])], 10 | controllers: [UsersController], 11 | providers: [ 12 | { 13 | provide: Services.USERS, 14 | useClass: UsersService, 15 | }, 16 | ], 17 | exports: [ 18 | { 19 | provide: Services.USERS, 20 | useClass: UsersService, 21 | }, 22 | ], 23 | }) 24 | export class UsersModule {} 25 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { DeepPartial, Repository } from 'typeorm'; 3 | import { User } from './entities/user.entity'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { CreateUserDto } from './dtos/create-user.dto'; 6 | import { EntityCondition } from 'src/utils/types/entity-condition.type'; 7 | import { NullableType } from 'src/utils/types/nullable.type'; 8 | import { IPaginationOptions } from 'src/utils/types/pagination-options'; 9 | import { IUsersService } from './users'; 10 | 11 | @Injectable() 12 | export class UsersService implements IUsersService { 13 | constructor( 14 | @InjectRepository(User) 15 | private readonly usersRepository: Repository, 16 | ) {} 17 | 18 | async createUser(createUserDto: CreateUserDto): Promise { 19 | const existingUser = await this.usersRepository.findOne({ 20 | where: { email: createUserDto.email }, 21 | }); 22 | if (existingUser) 23 | throw new HttpException('User already exists', HttpStatus.CONFLICT); 24 | const user = this.usersRepository.create(createUserDto); 25 | return this.usersRepository.save(user); 26 | } 27 | 28 | findOneUser(options: EntityCondition): Promise> { 29 | return this.usersRepository.findOne({ 30 | where: options, 31 | }); 32 | } 33 | 34 | findUsersWithPagination( 35 | paginationOptions: IPaginationOptions, 36 | ): Promise { 37 | return this.usersRepository.find({ 38 | skip: (paginationOptions.page - 1) * paginationOptions.limit, 39 | take: paginationOptions.limit, 40 | }); 41 | } 42 | 43 | updateUser(id: User['id'], payload: DeepPartial): Promise { 44 | return this.usersRepository.save( 45 | this.usersRepository.create({ 46 | id, 47 | ...payload, 48 | }), 49 | ); 50 | } 51 | 52 | async deleteUser(id: User['id']): Promise { 53 | await this.usersRepository.softDelete(id); 54 | } 55 | 56 | async saveUser(user: User): Promise { 57 | return this.usersRepository.save(user); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/users/users.ts: -------------------------------------------------------------------------------- 1 | import { EntityCondition } from 'src/utils/types/entity-condition.type'; 2 | import { CreateUserDto } from './dtos/create-user.dto'; 3 | import { User } from './entities/user.entity'; 4 | import { NullableType } from 'src/utils/types/nullable.type'; 5 | import { IPaginationOptions } from 'src/utils/types/pagination-options'; 6 | import { DeepPartial } from 'typeorm'; 7 | 8 | export interface IUsersService { 9 | createUser(createUserDto: CreateUserDto): Promise; 10 | findOneUser(options: EntityCondition): Promise>; 11 | findUsersWithPagination( 12 | paginationOptions: IPaginationOptions, 13 | ): Promise; 14 | updateUser(id: User['id'], payload: DeepPartial): Promise; 15 | deleteUser(id: User['id']): Promise; 16 | saveUser(user: User): Promise; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export enum Routes { 2 | AUTH = 'auth', 3 | AUTH_GOOGLE = 'auth/google', 4 | USERS = 'users', 5 | } 6 | 7 | export enum Services { 8 | AUTH = 'AUTH_SERVICE', 9 | USERS = 'USERS_SERVICE', 10 | SESSION = 'SESSION_SERVICE', 11 | MAILER = 'MAILER_SERVICE', 12 | MAILS = 'MAILS_SERVICE', 13 | FORGOT_PASSWORD = 'FORGOT_PASSWORD_SERVICE', 14 | AUTH_GOOGLE = 'AUTH_GOOGLE_SERVICE', 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/entity-helper.ts: -------------------------------------------------------------------------------- 1 | import { instanceToPlain } from 'class-transformer'; 2 | import { AfterLoad, BaseEntity } from 'typeorm'; 3 | 4 | export class EntityHelper extends BaseEntity { 5 | __entity?: string; 6 | 7 | @AfterLoad() 8 | setEntityName() { 9 | this.__entity = this.constructor.name; 10 | } 11 | 12 | toJSON() { 13 | return instanceToPlain(this); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | 3 | export async function hashPassword(rawPassword: string) { 4 | const salt = await bcrypt.genSalt(); 5 | return bcrypt.hash(rawPassword, salt); 6 | } 7 | 8 | export async function compareHash(rawPassword: string, hashedPassword: string) { 9 | return await bcrypt.compare(rawPassword, hashedPassword); 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/transformers/lower-case.transformer.ts: -------------------------------------------------------------------------------- 1 | import { TransformFnParams } from 'class-transformer/types/interfaces'; 2 | import { MaybeType } from '../types/maybe.type'; 3 | 4 | export const lowerCaseTransformer = ( 5 | params: TransformFnParams, 6 | ): MaybeType => params.value?.toLowerCase().trim(); 7 | -------------------------------------------------------------------------------- /src/utils/types/entity-condition.type.ts: -------------------------------------------------------------------------------- 1 | import { FindOptionsWhere } from 'typeorm'; 2 | 3 | export type EntityCondition = FindOptionsWhere; 4 | -------------------------------------------------------------------------------- /src/utils/types/find-options.type.ts: -------------------------------------------------------------------------------- 1 | import { EntityCondition } from './entity-condition.type'; 2 | 3 | export type FindOptions = { 4 | where: EntityCondition[] | EntityCondition; 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/types/infinity-pagination.type.ts: -------------------------------------------------------------------------------- 1 | export type InfinityPaginationType = Readonly<{ 2 | data: T[]; 3 | hasNextPage: boolean; 4 | }>; 5 | -------------------------------------------------------------------------------- /src/utils/types/maybe.type.ts: -------------------------------------------------------------------------------- 1 | export type MaybeType = T | undefined; 2 | -------------------------------------------------------------------------------- /src/utils/types/nullable.type.ts: -------------------------------------------------------------------------------- 1 | export type NullableType = T | null; 2 | -------------------------------------------------------------------------------- /src/utils/types/or-never.type.ts: -------------------------------------------------------------------------------- 1 | export type OrNeverType = T | never; 2 | -------------------------------------------------------------------------------- /src/utils/types/pagination-options.ts: -------------------------------------------------------------------------------- 1 | export interface IPaginationOptions { 2 | page: number; 3 | limit: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/validate-config.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | import { validateSync } from 'class-validator'; 3 | import { ClassConstructor } from 'class-transformer/types/interfaces'; 4 | 5 | function validateConfig( 6 | config: Record, 7 | envVariablesClass: ClassConstructor, 8 | ) { 9 | const validatedConfig = plainToClass(envVariablesClass, config, { 10 | enableImplicitConversion: true, 11 | }); 12 | const errors = validateSync(validatedConfig, { 13 | skipMissingProperties: false, 14 | }); 15 | 16 | if (errors.length > 0) { 17 | throw new Error(errors.toString()); 18 | } 19 | return validatedConfig; 20 | } 21 | 22 | export default validateConfig; 23 | -------------------------------------------------------------------------------- /src/utils/validation-options.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpException, 3 | HttpStatus, 4 | ValidationError, 5 | ValidationPipeOptions, 6 | } from '@nestjs/common'; 7 | 8 | const validationOptions: ValidationPipeOptions = { 9 | transform: true, 10 | whitelist: true, 11 | errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, 12 | exceptionFactory: (errors: ValidationError[]) => 13 | new HttpException( 14 | { 15 | status: HttpStatus.UNPROCESSABLE_ENTITY, 16 | errors: errors.reduce( 17 | (accumulator, currentValue) => ({ 18 | ...accumulator, 19 | [currentValue.property]: Object.values( 20 | currentValue.constraints ?? {}, 21 | ).join(', '), 22 | }), 23 | {}, 24 | ), 25 | }, 26 | HttpStatus.UNPROCESSABLE_ENTITY, 27 | ), 28 | }; 29 | 30 | export default validationOptions; 31 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /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 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "esModuleInterop": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------