├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── decorators │ │ ├── get-curr-user.decorator.ts │ │ ├── public.decorator.ts │ │ └── roles.decorator.ts │ ├── dto │ │ ├── login.dto.ts │ │ ├── payload.dto.ts │ │ └── reset-password.dto.ts │ ├── enum │ │ └── roles.enum.ts │ ├── guards │ │ ├── jwt.guard.ts │ │ ├── role.guard.ts │ │ └── verifed-user.guard.ts │ └── strategies │ │ ├── facebook.strategy.ts │ │ ├── google.strategy.ts │ │ └── jwt.strategy.ts ├── common │ ├── config │ │ └── config.module.ts │ ├── database │ │ ├── abstract.repo.ts │ │ ├── abstract.schema.ts │ │ └── database.module.ts │ └── index.ts ├── mail │ ├── mail.module.ts │ ├── mail.service.ts │ ├── models │ │ └── otp.schema.ts │ └── repository │ │ └── otp.repo.ts ├── main.ts ├── payment │ ├── dto │ │ ├── create-payment.dto.ts │ │ └── update-payment.dto.ts │ ├── models │ │ └── payment.schema.ts │ ├── payment.controller.ts │ ├── payment.module.ts │ └── payment.service.ts └── user │ ├── dto │ ├── create-user.dto.ts │ ├── get-user.dto.ts │ └── update-user.dto.ts │ ├── models │ └── user.schema.ts │ ├── repository │ └── user.repo.ts │ ├── user.controller.ts │ ├── user.module.ts │ └── user.service.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 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Brevo", 4 | "getbrevo" 5 | ] 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS Payment Starter Project 2 | 3 | This is a **NestJS-based starter project** designed to provide a robust foundation for building applications with **authentication**, **authorization**, **email verification**, and **payment integration**. It is ideal for developers looking to kickstart their projects with a scalable and secure backend. 4 | 5 | ## Features 6 | 7 | - **Authentication & Authorization**: 8 | - JWT-based authentication (Access & Refresh Tokens). 9 | - Role-based access control (RBAC). 10 | - Guards for public routes, role-based routes, and verified user routes. 11 | 12 | - **User Management**: 13 | - User registration and login. 14 | - Email verification using OTP. 15 | - Password reset functionality. 16 | 17 | - **Payment Integration**: 18 | - Paymob payment gateway integration. 19 | - Secure payment initialization and webhook handling. 20 | 21 | - **Email Service**: 22 | - Email verification and password reset emails using Brevo (formerly Sendinblue). 23 | 24 | - **Database**: 25 | - MongoDB integration using Mongoose. 26 | - Abstract repository pattern for database operations. 27 | 28 | - **Code Quality**: 29 | - Fully typed with TypeScript. 30 | - Validation using `class-validator`. 31 | - Linting with ESLint and formatting with Prettier. 32 | 33 | - **Testing**: 34 | - End-to-end testing setup with Jest and Supertest. 35 | 36 | ## Prerequisites 37 | 38 | - **Node.js**: v16 or higher 39 | - **MongoDB**: A running MongoDB instance 40 | - **Environment Variables**: Configure the `.env` file (see [Setup](#setup)) 41 | 42 | ## Setup 43 | 44 | 1. **Clone the Repository**: 45 | ```bash 46 | git clone https://github.com/your-username/NestJs_payment.git 47 | cd NestJs_payment 48 | ``` 49 | 50 | 2. **Install Dependencies**: 51 | ```bash 52 | npm install 53 | ``` 54 | 55 | 3. **Configure Environment Variables**: 56 | Create a `.env` file in the root directory and configure the following variables: 57 | ```env 58 | PORT=5000 59 | DATABASE_URI=your_mongodb_connection_string 60 | JWT_ACCESS_SECRET=your_access_token_secret 61 | JWT_REFRESH_SECRET=your_refresh_token_secret 62 | JWT_ACCESS_EXP=1h 63 | JWT_REFRESH_EXP=7d 64 | BREVO_API_KEY=your_brevo_api_key 65 | EMAIL_FROM=your_email_address 66 | PAYMOB_API_KEY=your_paymob_api_key 67 | PAYMOB_INTEGRATION_ID=your_integration_id 68 | PAYMOB_IFRAME_ID=your_iframe_id 69 | PAYMOB_HMAC_SECRET=your_hmac_secret 70 | ``` 71 | 72 | 4. **Run the Application**: 73 | ```bash 74 | npm run start:dev 75 | ``` 76 | 77 | 5. **Access the API**: 78 | The API will be available at `http://localhost:5000/api`. 79 | 80 | ## Project Structure 81 | 82 | ``` 83 | src/ 84 | ├── auth/ # Authentication & Authorization 85 | ├── common/ # Shared modules and utilities 86 | ├── mail/ # Email service and OTP management 87 | ├── payment/ # Payment integration with Paymob 88 | ├── user/ # User management 89 | ├── app.module.ts # Root module 90 | └── main.ts # Application entry point 91 | ``` 92 | 93 | ## API Endpoints 94 | 95 | ### Authentication 96 | - `POST /auth/login`: User login 97 | - `POST /auth/signup`: User registration 98 | - `POST /auth/refresh`: Refresh access token 99 | - `POST /auth/reset-password`: Reset password 100 | - `POST /auth/verify-otp`: Verify email OTP 101 | 102 | ### User 103 | - `GET /user/profile/me`: Get current user profile 104 | - `GET /user`: Get all users (Admin only) 105 | - `GET /user/:id`: Get user by ID (Admin only) 106 | - `PATCH /user/:id`: Update user (Admin only) 107 | 108 | ### Payment 109 | - `POST /payment/initialize`: Initialize a payment 110 | - `POST /payment/webhook`: Handle payment webhook 111 | 112 | ## Testing 113 | 114 | Run the tests using Jest: 115 | ```bash 116 | npm run test 117 | ``` 118 | 119 | Run end-to-end tests: 120 | ```bash 121 | npm run test:e2e 122 | ``` 123 | 124 | ## Technologies Used 125 | 126 | - **NestJS**: Backend framework 127 | - **MongoDB**: Database 128 | - **Mongoose**: ODM for MongoDB 129 | - **Passport**: Authentication middleware 130 | - **Brevo**: Email service 131 | - **Paymob**: Payment gateway 132 | - **TypeScript**: Strongly typed JavaScript 133 | - **Jest**: Testing framework 134 | 135 | ## Contributing 136 | 137 | Contributions are welcome! Feel free to fork the repository and submit a pull request. 138 | 139 | ## License 140 | 141 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 142 | 143 | ## Contact 144 | 145 | For any inquiries or support, please contact [serag.eldien.mahmoud@gmail.com](mailto:serag.eldien.mahmoud@gmail.com). 146 | 147 | --- 148 | **Happy Coding!** 149 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NestJs_payment", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@getbrevo/brevo": "^2.2.0", 24 | "@nestjs/common": "^10.0.0", 25 | "@nestjs/config": "^4.0.1", 26 | "@nestjs/core": "^10.0.0", 27 | "@nestjs/jwt": "^11.0.0", 28 | "@nestjs/mapped-types": "*", 29 | "@nestjs/mongoose": "^11.0.1", 30 | "@nestjs/passport": "^11.0.5", 31 | "@nestjs/platform-express": "^10.0.0", 32 | "axios": "^1.8.4", 33 | "bcrypt": "^5.1.1", 34 | "class-transformer": "^0.5.1", 35 | "class-validator": "^0.14.1", 36 | "cookie-parser": "^1.4.7", 37 | "csurf": "^1.10.0", 38 | "joi": "^17.13.3", 39 | "jsonwebtoken": "^9.0.2", 40 | "mongoose": "^8.12.1", 41 | "passport": "^0.7.0", 42 | "passport-facebook": "^3.0.0", 43 | "passport-google-oauth20": "^2.0.0", 44 | "passport-jwt": "^4.0.1", 45 | "reflect-metadata": "^0.2.0", 46 | "rxjs": "^7.8.1" 47 | }, 48 | "devDependencies": { 49 | "@nestjs/cli": "^10.0.0", 50 | "@nestjs/schematics": "^10.0.0", 51 | "@nestjs/testing": "^10.0.0", 52 | "@types/bcrypt": "^5.0.2", 53 | "@types/express": "^4.17.17", 54 | "@types/jest": "^29.5.2", 55 | "@types/node": "^20.3.1", 56 | "@types/passport-jwt": "^4.0.1", 57 | "@types/supertest": "^6.0.0", 58 | "@typescript-eslint/eslint-plugin": "^8.0.0", 59 | "@typescript-eslint/parser": "^8.0.0", 60 | "eslint": "^8.42.0", 61 | "eslint-config-prettier": "^9.0.0", 62 | "eslint-plugin-prettier": "^5.0.0", 63 | "jest": "^29.5.0", 64 | "prettier": "^3.0.0", 65 | "source-map-support": "^0.5.21", 66 | "supertest": "^7.0.0", 67 | "ts-jest": "^29.1.0", 68 | "ts-loader": "^9.4.3", 69 | "ts-node": "^10.9.1", 70 | "tsconfig-paths": "^4.2.0", 71 | "typescript": "^5.1.3" 72 | }, 73 | "jest": { 74 | "moduleFileExtensions": [ 75 | "js", 76 | "json", 77 | "ts" 78 | ], 79 | "rootDir": "src", 80 | "testRegex": ".*\\.spec\\.ts$", 81 | "transform": { 82 | "^.+\\.(t|j)s$": "ts-jest" 83 | }, 84 | "collectCoverageFrom": [ 85 | "**/*.(t|j)s" 86 | ], 87 | "coverageDirectory": "../coverage", 88 | "testEnvironment": "node" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { PaymentModule } from './payment/payment.module'; 5 | import { UserModule } from './user/user.module'; 6 | import { AuthModule } from './auth/auth.module'; 7 | import { DatabaseModule } from './common/database/database.module'; 8 | import { ConfigModule } from './common/config/config.module'; 9 | import { MailModule } from './mail/mail.module'; 10 | import { APP_GUARD } from '@nestjs/core'; 11 | import { RolesGuard } from './auth/guards/role.guard'; 12 | import { JwtAuthGuard } from './auth/guards/jwt.guard'; 13 | import { VerifiedGuard } from './auth/guards/verifed-user.guard'; 14 | 15 | @Module({ 16 | imports: [ 17 | PaymentModule, 18 | AuthModule, 19 | UserModule, 20 | DatabaseModule, 21 | ConfigModule, 22 | MailModule, 23 | 24 | ], 25 | controllers: [AppController], 26 | providers: [AppService, 27 | { 28 | provide: APP_GUARD, 29 | useClass: JwtAuthGuard, // First, ensure user is authenticated 30 | }, 31 | { 32 | // this guard will be applied to all routes // but not the public routes 33 | provide: APP_GUARD, 34 | useClass: RolesGuard, 35 | }, 36 | { 37 | // this guard will be applied to all routes // but not the public routes 38 | provide: APP_GUARD, 39 | useClass: VerifiedGuard, 40 | }, 41 | ] 42 | }) 43 | export class AppModule { } 44 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | NotFoundException, 6 | Post, 7 | Req, 8 | Res, 9 | UnauthorizedException, 10 | } from '@nestjs/common'; 11 | import { AuthService } from './auth.service'; 12 | import { LoginDto } from './dto/login.dto'; 13 | import { Response, Request } from 'express'; 14 | import { CreateUserDto } from 'src/user/dto/create-user.dto'; 15 | import { UserDto } from 'src/user/dto/get-user.dto'; 16 | import { Public } from './decorators/public.decorator'; 17 | import { CurrentUser } from './decorators/get-curr-user.decorator'; 18 | import { PayloadDto } from './dto/payload.dto'; 19 | import { RestPasswordDto } from './dto/reset-password.dto'; 20 | import { UserModel } from 'src/user/models/user.schema'; 21 | import { log } from 'console'; 22 | 23 | @Controller('auth') 24 | export class AuthController { 25 | constructor(private readonly authService: AuthService) { } 26 | 27 | @Public() 28 | @HttpCode(200) 29 | @Post('login') 30 | async login(@Body() credentials: LoginDto, @Res({ passthrough: true }) res: Response) { 31 | const user = await this.authService.validateUser( 32 | credentials.email, 33 | credentials.password, 34 | ); 35 | const accessToken = await this.authService.generateAccessToken(user); 36 | const refreshToken = await this.authService.generateRefreshToken(user); 37 | 38 | res.cookie('refreshToken', refreshToken, { 39 | httpOnly: true, 40 | secure: process.env.NODE_ENV === 'production', 41 | sameSite: 'strict', 42 | }); 43 | 44 | return { 45 | token: accessToken, 46 | user: new UserDto(user) 47 | } 48 | } 49 | 50 | @Public() 51 | @Post('signup') 52 | @HttpCode(201) 53 | async signup(@Body() credentials: CreateUserDto, @Res({ passthrough: true }) res: Response) { 54 | const user = await this.authService.signup(credentials); 55 | const accessToken = await this.authService.generateAccessToken(user); 56 | const refreshToken = await this.authService.generateRefreshToken(user); 57 | 58 | res.cookie('refreshToken', refreshToken, { 59 | httpOnly: true, 60 | secure: process.env.NODE_ENV === 'production', 61 | sameSite: 'strict', 62 | }); 63 | 64 | return { 65 | token: accessToken, 66 | user: new UserDto(user) 67 | } 68 | } 69 | 70 | @Post('refresh') 71 | @HttpCode(200) 72 | async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) { 73 | const refreshToken = req.cookies?.refreshToken; 74 | 75 | if (!refreshToken) { 76 | throw new UnauthorizedException('No refresh token provided'); 77 | } 78 | 79 | const user = await this.authService.verifyRefreshToken(refreshToken); 80 | 81 | const accessToken = await this.authService.generateAccessToken(user); 82 | const newRefreshToken = await this.authService.generateRefreshToken(user); 83 | 84 | res.cookie('refreshToken', newRefreshToken, { 85 | httpOnly: true, 86 | secure: process.env.NODE_ENV === 'production', 87 | sameSite: 'strict', 88 | }); 89 | 90 | return { token: accessToken }; 91 | } 92 | 93 | @Post('reset-password') 94 | @HttpCode(200) 95 | async resetPassword(@CurrentUser() user: PayloadDto, @Body() resetPasswordDto: RestPasswordDto) { 96 | if (!user) { 97 | throw new NotFoundException("User not found in the request") 98 | } 99 | 100 | await this.authService.resetPassword(user.sub, resetPasswordDto) 101 | } 102 | 103 | @Post('verify-otp') 104 | async verifyOtp(@Body('otp') otp: string, @CurrentUser() user: UserModel) { 105 | log(user) 106 | return await this.authService.verifyOtp(user.email, otp) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /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 { UserModule } from 'src/user/user.module'; 5 | import { JwtModule, JwtService } from '@nestjs/jwt'; 6 | import { ConfigModule, ConfigService } from '@nestjs/config'; 7 | import { MailModule } from 'src/mail/mail.module'; 8 | import { PassportModule } from '@nestjs/passport'; 9 | import { JwtStrategy } from './strategies/jwt.strategy'; 10 | 11 | 12 | 13 | @Module({ 14 | imports: [ 15 | PassportModule.register({ 16 | defaultStrategy: 'jwt', 17 | property: 'user', 18 | }), 19 | UserModule, 20 | JwtModule.registerAsync({ 21 | imports: [ConfigModule], 22 | useFactory: async (configService) => ({ 23 | secret: configService.get('JWT_ACCESS_SECRET'), 24 | signOptions: { expiresIn: configService.get('JWT_ACCESS_EXP') }, 25 | }), 26 | inject: [ConfigService], 27 | }), 28 | MailModule 29 | ], 30 | 31 | controllers: [AuthController], 32 | providers: [AuthService, JwtService, JwtStrategy], 33 | }) 34 | export class AuthModule { } 35 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException, Injectable, Logger, NotAcceptableException, NotFoundException, UnauthorizedException } from '@nestjs/common'; 2 | import { UserRepo } from 'src/user/repository/user.repo'; 3 | import * as bcrypt from 'bcrypt'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { UserModel } from 'src/user/models/user.schema'; 6 | import { CreateUserDto } from 'src/user/dto/create-user.dto'; 7 | import { ConfigService } from '@nestjs/config'; 8 | import { MailService } from 'src/mail/mail.service'; 9 | import { PayloadDto } from './dto/payload.dto'; 10 | import { OtpRepo } from 'src/mail/repository/otp.repo'; 11 | import { RestPasswordDto } from './dto/reset-password.dto'; 12 | 13 | @Injectable() 14 | export class AuthService { 15 | constructor( 16 | private readonly userRepo: UserRepo, 17 | private readonly jwtService: JwtService, 18 | private readonly configService: ConfigService, 19 | private readonly mailService: MailService, 20 | private readonly otpRepo: OtpRepo 21 | 22 | ) { } 23 | 24 | 25 | private readonly logger = new Logger(AuthService.name) 26 | 27 | 28 | async validateUser(email: string, password: string) { 29 | const user = await this.userRepo.findOne({ email }); 30 | if (!user) { 31 | throw new NotFoundException('User not found'); 32 | } 33 | // compare hashed password 34 | const isPasswordMatch = await bcrypt.compare(password, user.password); // Compare the password 35 | if (!isPasswordMatch) { 36 | throw new NotFoundException('Invalid Credentials'); 37 | } 38 | 39 | return user; 40 | } 41 | 42 | async signup(credentials: CreateUserDto) { 43 | 44 | // check if user already exists 45 | const user = await this.userRepo.findOne({ email: credentials.email }); 46 | if (user) { 47 | throw new NotFoundException('User already exists'); 48 | } 49 | 50 | // hash the password 51 | const hashedPassword = await bcrypt.hash(credentials.password, 10); 52 | credentials.password = hashedPassword; 53 | 54 | const newUser = await this.userRepo.create(credentials); 55 | await this.mailService.sendVerificationEmail(newUser); 56 | return newUser; 57 | } 58 | 59 | // U can put expire time within the env file 60 | async generateAccessToken(user: UserModel) { 61 | const payload: PayloadDto = { email: user.email, sub: user._id.toString(), role: user.role }; 62 | return this.jwtService.sign(payload, { 63 | secret: this.configService.get('JWT_ACCESS_SECRET'), 64 | expiresIn: this.configService.get('JWT_ACCESS_EXP') 65 | }); 66 | } 67 | 68 | async generateRefreshToken(user: UserModel) { 69 | const payload: PayloadDto = { email: user.email, sub: user._id.toString() }; 70 | return this.jwtService.sign(payload, { 71 | secret: this.configService.get('JWT_REFRESH_SECRET'), 72 | expiresIn: this.configService.get('JWT_REFRESH_EXP') 73 | }); 74 | } 75 | 76 | async verifyRefreshToken(refreshToken: string) { 77 | 78 | let user: UserModel; 79 | 80 | try { 81 | const payload: PayloadDto = this.jwtService.verify(refreshToken, { secret: this.configService.get('JWT_REFRESH_SECRET') }); 82 | user = await this.userRepo.findOne({ _id: payload.sub }); 83 | 84 | } catch (err) { 85 | this.logger.error(err) 86 | throw new ForbiddenException("Access Denied or Invalid Sign") 87 | } 88 | 89 | if (!user) { 90 | throw new NotFoundException('User not found'); 91 | } 92 | 93 | return user; 94 | } 95 | 96 | async resetPassword(userId: string, resetPasswordDto: RestPasswordDto) { 97 | 98 | 99 | const user = await this.userRepo.findOne({ userId }) 100 | if (!user) { 101 | throw new NotFoundException("User not found") 102 | } 103 | const isMatching = await bcrypt.compare(resetPasswordDto.oldPassword, user.password) 104 | if (!isMatching) { 105 | throw new NotAcceptableException("Wrong Credentials") 106 | } 107 | 108 | // encrypt new password 109 | const hashedPassword = await bcrypt.hash(user.password, 10); 110 | 111 | const updatedUser = await this.userRepo.findOneAndUpdate({ userId }, { 112 | password: hashedPassword 113 | }) 114 | 115 | return updatedUser; 116 | 117 | } 118 | 119 | async verifyOtp(email: string, otp: string) { 120 | 121 | const userOtp = await this.otpRepo.getOtp(email); 122 | if (!userOtp) { 123 | throw new NotFoundException('OTP not found'); 124 | } 125 | const isOtpValid = await bcrypt.compare(otp, userOtp.otp); 126 | if (!isOtpValid) { 127 | throw new UnauthorizedException('Invalid OTP'); 128 | } 129 | 130 | const user = await this.userRepo.findOne({ email }); 131 | if (!user) { 132 | throw new NotFoundException('User not found'); 133 | } 134 | 135 | await this.otpRepo.deleteOtp(email); 136 | user.isVerified = true; 137 | await this.userRepo.findOneAndUpdate({ email }, { isVerified: true }); 138 | 139 | return { message: 'OTP verified successfully' }; 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /src/auth/decorators/get-curr-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const CurrentUser = createParamDecorator( 4 | (data: keyof any, ctx: ExecutionContext) => { 5 | const request = ctx.switchToHttp().getRequest(); 6 | 7 | 8 | // in case if we only need specific data from the user 9 | return data ? request.user?.[data] : request.user; 10 | }, 11 | ); 12 | -------------------------------------------------------------------------------- /src/auth/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Public = () => SetMetadata('isPublic', true); 4 | -------------------------------------------------------------------------------- /src/auth/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { Role } from '../enum/roles.enum'; 3 | 4 | export const ROLES_KEY = 'roles'; 5 | export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); -------------------------------------------------------------------------------- /src/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString, Length } from 'class-validator'; 2 | 3 | export class LoginDto { 4 | @IsEmail() 5 | email: string; 6 | 7 | @IsString() 8 | @Length(6, 20) 9 | password: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/auth/dto/payload.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsEnum, IsMongoId } from 'class-validator'; 2 | import { Role } from '../enum/roles.enum'; 3 | 4 | export class PayloadDto { 5 | @IsEnum(Role) 6 | role?: Role; 7 | 8 | @IsEmail() 9 | email: string; 10 | 11 | @IsMongoId() 12 | sub: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/auth/dto/reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, Length } from 'class-validator'; 2 | 3 | export class RestPasswordDto { 4 | @IsString() 5 | @Length(6, 20) 6 | oldPassword: string; 7 | 8 | @IsString() 9 | @Length(6, 20) 10 | newPassword: string; 11 | } -------------------------------------------------------------------------------- /src/auth/enum/roles.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | ADMIN = 'admin', 3 | USER = 'user', 4 | } 5 | -------------------------------------------------------------------------------- /src/auth/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class JwtAuthGuard extends AuthGuard('jwt') { 7 | 8 | 9 | constructor(private readonly reflector: Reflector) { 10 | super(); 11 | } 12 | 13 | canActivate(context: ExecutionContext) { 14 | // see the metadata if isPublic is set to true then return true 15 | const isPublic = this.reflector.getAllAndOverride('isPublic', [ 16 | context.getHandler(), 17 | context.getClass(), 18 | ]); 19 | 20 | // iff this route is public then return true 21 | if (isPublic) { 22 | return isPublic; 23 | } 24 | 25 | return super.canActivate(context); // Runs JWT validation 26 | 27 | } 28 | } -------------------------------------------------------------------------------- /src/auth/guards/role.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | 4 | @Injectable() 5 | export class RolesGuard implements CanActivate { 6 | constructor(private reflector: Reflector) { } 7 | 8 | canActivate(context: ExecutionContext): boolean { 9 | const requiredRoles = this.reflector.get('roles', context.getHandler()); 10 | 11 | const request = context.switchToHttp().getRequest(); 12 | const user = request.user; 13 | 14 | // Check if the route is public 15 | if (this.reflector.getAllAndOverride('isPublic', [ 16 | context.getHandler(), 17 | context.getClass(), 18 | ])) { 19 | return true; 20 | } 21 | 22 | // If the user is not authenticated, throw UnauthorizedException 23 | if (!user) { 24 | throw new UnauthorizedException('User is not authenticated'); 25 | } 26 | 27 | // If no specific roles are required, allow any authenticated user 28 | if (!requiredRoles || requiredRoles.length === 0) { 29 | return true; 30 | } 31 | 32 | // If the user does not have the required role, throw ForbiddenException 33 | if (!requiredRoles.includes(user.role)) { 34 | throw new ForbiddenException('User does not have the required role'); 35 | } 36 | 37 | return true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/auth/guards/verifed-user.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | 4 | @Injectable() 5 | export class VerifiedGuard implements CanActivate { 6 | constructor(private reflector: Reflector) { } 7 | 8 | canActivate(context: ExecutionContext): boolean { 9 | 10 | const isPublic = this.reflector.get('isPublic', context.getHandler()); 11 | if (isPublic) return true; 12 | 13 | // Get the current request and user from the context 14 | const request = context.switchToHttp().getRequest(); 15 | const user = request.user; 16 | 17 | // If the user is not authenticated, throw ForbiddenException 18 | if (!user) { 19 | throw new ForbiddenException('You must be logged in'); 20 | } 21 | 22 | // Check if the route is 'verify-otp' (no need to check verification for this route) 23 | const isVerifyOtpRoute = request.url.includes('verify-otp'); 24 | if (isVerifyOtpRoute) { 25 | return true; // Allow access to the verify-otp route regardless of the verification status 26 | } 27 | 28 | // Enforce that the user is verified for all other routes 29 | if (!user.isVerified) { 30 | throw new ForbiddenException('Your account is not verified. Please verify your email.'); 31 | } 32 | 33 | return true; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/auth/strategies/facebook.strategy.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzSerag/NestJs_Paymob/fde3d70c45d5e70edac18e26c3ebd2910db91889/src/auth/strategies/facebook.strategy.ts -------------------------------------------------------------------------------- /src/auth/strategies/google.strategy.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzSerag/NestJs_Paymob/fde3d70c45d5e70edac18e26c3ebd2910db91889/src/auth/strategies/google.strategy.ts -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { PayloadDto } from '../dto/payload.dto'; 6 | import { UserRepo } from 'src/user/repository/user.repo'; 7 | import { UserModel } from 'src/user/models/user.schema'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 11 | constructor(private readonly configService: ConfigService, 12 | private readonly userRepo: UserRepo 13 | ) { 14 | super({ 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | ignoreExpiration: false, 17 | secretOrKey: configService.get('JWT_ACCESS_SECRET'), 18 | 19 | }); 20 | } 21 | 22 | async validate(payload: PayloadDto) { 23 | 24 | if (!payload || !payload.sub) { 25 | throw new UnauthorizedException('Invalid token'); 26 | } 27 | 28 | const user: UserModel = await this.userRepo.findOne({ _id: payload.sub }) 29 | return user; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/common/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule as NestConfigModule } from '@nestjs/config'; 3 | import * as Joi from 'joi'; 4 | 5 | @Module({ 6 | imports: [ 7 | NestConfigModule.forRoot({ 8 | isGlobal: true, 9 | envFilePath: '.env', 10 | validationSchema: Joi.object({ 11 | NODE_ENV: Joi.string() 12 | .valid('development', 'production', 'test') 13 | .default('development'), 14 | PORT: Joi.number().default(5000), 15 | DATABASE_URI: Joi.string().required(), 16 | JWT_ACCESS_SECRET: Joi.string().required(), 17 | JWT_REFRESH_SECRET: Joi.string().required(), 18 | JWT_ACCESS_EXP: Joi.string().required(), 19 | JWT_REFRESH_EXP: Joi.string().required(), 20 | BREVO_API_KEY: Joi.string().required(), 21 | EMAIL_FROM: Joi.string().required(), 22 | 23 | }), 24 | }), 25 | ], 26 | }) 27 | export class ConfigModule { } 28 | -------------------------------------------------------------------------------- /src/common/database/abstract.repo.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NotFoundException } from '@nestjs/common'; 2 | import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose'; 3 | import { AbstractModel } from './abstract.schema'; 4 | 5 | @Injectable() 6 | export abstract class AbstractRepo { 7 | protected readonly logger = new Logger(AbstractRepo.name); 8 | 9 | constructor(private readonly model: Model) {} 10 | 11 | async create(document: Omit): Promise { 12 | const created = new this.model({ 13 | ...document, 14 | _id: new Types.ObjectId(), 15 | }); 16 | 17 | return (await created.save()).toJSON() as TSchema; 18 | } 19 | 20 | async findOne(filterQuery: FilterQuery): Promise { 21 | const document = this.model.findOne(filterQuery).lean(true); 22 | 23 | if (!document) { 24 | this.logger.warn( 25 | `Document not found with filter: ${JSON.stringify(filterQuery)}`, 26 | ); 27 | throw new NotFoundException('Document not found'); 28 | } 29 | 30 | return document; 31 | } 32 | 33 | async find(filterQuery: FilterQuery): Promise { 34 | const document = this.model.find(filterQuery).lean(true); 35 | 36 | if (!document) { 37 | this.logger.warn( 38 | `Documents not found with filter: ${JSON.stringify(filterQuery)}`, 39 | ); 40 | throw new NotFoundException('Documents not found'); 41 | } 42 | return document; 43 | } 44 | 45 | async findOneAndUpdate( 46 | filterQuery: FilterQuery, 47 | updateQuery: UpdateQuery, 48 | ): Promise { 49 | const document = this.model 50 | .findOneAndUpdate(filterQuery, updateQuery, { 51 | new: true, 52 | }) 53 | .lean(true); 54 | 55 | if (!document) { 56 | this.logger.warn( 57 | `Document not found to update: ${JSON.stringify(filterQuery)}`, 58 | ); 59 | throw new NotFoundException('Document not found to update'); 60 | } 61 | 62 | return document; 63 | } 64 | 65 | async findOneAndDelete( 66 | filterQuery: FilterQuery, 67 | ): Promise { 68 | const document = this.model 69 | .findOneAndDelete(filterQuery) 70 | .lean(true); 71 | 72 | if (!document) { 73 | this.logger.warn( 74 | `Document not found to delete: ${JSON.stringify(filterQuery)}`, 75 | ); 76 | throw new NotFoundException('Document not found to delete'); 77 | } 78 | 79 | return document; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/common/database/abstract.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema } from '@nestjs/mongoose'; 2 | import { Document, SchemaTypes, Types } from 'mongoose'; 3 | 4 | @Schema({ timestamps: true }) 5 | export abstract class AbstractModel { 6 | @Prop({ type: SchemaTypes.ObjectId, auto: true }) 7 | _id: Types.ObjectId; 8 | } 9 | -------------------------------------------------------------------------------- /src/common/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ModelDefinition, MongooseModule } from '@nestjs/mongoose'; 3 | import { ConfigModule } from '../config/config.module'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | @Module({ 7 | imports: [ 8 | MongooseModule.forRootAsync({ 9 | imports: [ConfigModule], 10 | useFactory: async (configService) => ({ 11 | uri: configService.get('DATABASE_URI'), 12 | }), 13 | inject: [ConfigService], 14 | }), 15 | ], 16 | }) 17 | export class DatabaseModule { 18 | // abstract class to be used in the forFeature method 19 | static forFeature(models: ModelDefinition[]) { 20 | return MongooseModule.forFeature(models); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config/config.module'; 2 | export * from './database/abstract.schema'; 3 | export * from './database/abstract.repo'; 4 | -------------------------------------------------------------------------------- /src/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from 'src/common'; 3 | import { DatabaseModule } from 'src/common/database/database.module'; 4 | import { OtpModel, OtpSchema } from './models/otp.schema'; 5 | import { OtpRepo } from './repository/otp.repo'; 6 | import { MailService } from './mail.service'; 7 | 8 | @Module({ 9 | imports: [ConfigModule, DatabaseModule, DatabaseModule.forFeature([{ 10 | name: OtpModel.name, schema: OtpSchema 11 | }]),], 12 | providers: [MailService, OtpRepo], 13 | exports: [MailService, OtpRepo], 14 | }) 15 | export class MailModule { } 16 | -------------------------------------------------------------------------------- /src/mail/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NotFoundException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import * as Brevo from '@getbrevo/brevo'; 4 | import { UserModel } from 'src/user/models/user.schema'; 5 | import * as bcrypt from 'bcrypt' 6 | import { OtpRepo } from './repository/otp.repo'; 7 | 8 | @Injectable() 9 | export class MailService { 10 | private brevoClient: Brevo.TransactionalEmailsApi; 11 | 12 | constructor(private configService: ConfigService, private readonly otpRepo: OtpRepo) { 13 | const apiKey = this.configService.get('BREVO_API_KEY'); 14 | this.brevoClient = new Brevo.TransactionalEmailsApi(); 15 | this.brevoClient.setApiKey(Brevo.TransactionalEmailsApiApiKeys.apiKey, apiKey); 16 | } 17 | 18 | private readonly logger = new Logger(MailService.name); 19 | 20 | private verificationEmailTemplate(otp: string): string { 21 | 22 | return ` 23 | 24 | 25 | 26 | 27 | 28 | Email Verification 29 | 38 | 39 | 40 |
41 |
42 | Shatabha 43 |
44 |
45 |

Verify Your Email

46 |

Thank you for signing up for Shatabha! Use the code below to verify your email address:

47 |
${otp}
48 |

This code is valid for 10 minutes. If you didn’t request this, you can ignore this email.

49 |
50 | 54 |
55 | 56 | 57 | `; 58 | } 59 | 60 | private resetPasswordEmailTemplate(otp: string): string { 61 | 62 | 63 | return ` 64 |

Password Reset

65 |

Your OTP to reset your password is: ${otp}

66 |

This code will expire in 10 minutes. Do not share this code with anyone.

67 | `; 68 | } 69 | 70 | 71 | 72 | async sendVerificationEmail(user: UserModel) { 73 | 74 | // save the otp in the db 75 | const otp = this.generateOTP(); 76 | const hashedOtp = await bcrypt.hash(otp, 10); 77 | try { 78 | await this.otpRepo.saveOtp(user.email, hashedOtp) 79 | } catch (err) { 80 | this.logger.error('Cant Save Otp email' + err) 81 | throw new NotFoundException("Cant send Confirmation email") 82 | } 83 | 84 | const msg = { 85 | to: [{ email: user.email }], 86 | sender: { email: this.configService.get('EMAIL_FROM') }, 87 | subject: 'Verify your email - NestJS Payment Project', 88 | htmlContent: this.verificationEmailTemplate(otp), 89 | }; 90 | 91 | try { 92 | await this.brevoClient.sendTransacEmail(msg); 93 | } catch (error) { 94 | this.logger.error('Error sending verification email:', error); 95 | throw Error('Error sending verification email'); 96 | } 97 | } 98 | 99 | 100 | async sendResetOtpEmail(user: UserModel) { 101 | 102 | 103 | // save the otp in the db 104 | const otp = this.generateOTP(); 105 | const hashedOtp = await bcrypt.hash(otp, 10); 106 | try { 107 | await this.otpRepo.saveOtp(String(user._id), hashedOtp) 108 | } catch (err) { 109 | this.logger.error('Cant Save Otp email' + err) 110 | throw new NotFoundException("Cant send Confirmation email") 111 | } 112 | 113 | 114 | const msg = { 115 | to: [{ email: user.email }], 116 | sender: { email: this.configService.get('EMAIL_FROM') }, 117 | subject: 'your OTP reconfirmation', 118 | htmlContent: this.resetPasswordEmailTemplate(otp), 119 | }; 120 | 121 | try { 122 | await this.brevoClient.sendTransacEmail(msg); 123 | } catch (error) { 124 | this.logger.error('Error sending reset email:', error); 125 | throw Error('Error sending verification email'); 126 | } 127 | } 128 | 129 | 130 | 131 | 132 | private generateOTP() { 133 | // Generate a 6 digit OTP string 134 | return Math.floor(100000 + Math.random() * 900000).toString(); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/mail/models/otp.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 2 | import { AbstractModel } from "src/common"; 3 | 4 | 5 | @Schema() 6 | export class OtpModel extends AbstractModel { 7 | 8 | @Prop({ type: String }) 9 | email: string 10 | 11 | @Prop({ type: String, required: true }) 12 | otp: string // hashed otp 13 | 14 | @Prop({ required: true, expires: 20 * 60 * 1000 }) 15 | expireAt: Date 16 | 17 | } 18 | 19 | export const OtpSchema = SchemaFactory.createForClass(OtpModel) -------------------------------------------------------------------------------- /src/mail/repository/otp.repo.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from "@nestjs/common"; 2 | import { OtpModel } from "../models/otp.schema"; 3 | import { Model } from "mongoose"; 4 | import { AbstractRepo } from "src/common"; 5 | import { InjectModel } from "@nestjs/mongoose"; 6 | 7 | 8 | @Injectable() 9 | export class OtpRepo extends AbstractRepo { 10 | 11 | protected readonly logger = new Logger(OtpRepo.name); 12 | 13 | constructor( 14 | @InjectModel(OtpModel.name) private readonly otpModel: Model, 15 | ) { 16 | super(otpModel); 17 | } 18 | 19 | // save hashedOtp to that user 20 | async saveOtp(email: string, hashedOtp: string) { 21 | 22 | const otp = await this.otpModel.findOneAndUpdate( 23 | { email }, 24 | { 25 | otp: hashedOtp, 26 | expireAt: new Date(Date.now() + 20 * 60 * 1000) 27 | }, 28 | { 29 | upsert: true, // Create the document if it doesn't exist 30 | new: true, 31 | setDefaultsOnInsert: true 32 | } 33 | ); 34 | 35 | return otp; 36 | } 37 | 38 | async getOtp(email: string) { 39 | const otp = await this.otpModel.findOne({ email }) 40 | return otp 41 | } 42 | 43 | async deleteOtp(email: string) { 44 | await this.otpModel.deleteOne({ email }) 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory, Reflector } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common'; 4 | import * as cookieParser from 'cookie-parser'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | 9 | app.useGlobalPipes( 10 | new ValidationPipe({ 11 | whitelist: true, 12 | transform: true, 13 | forbidNonWhitelisted: true, 14 | transformOptions: { 15 | enableImplicitConversion: true, 16 | }, 17 | }), 18 | ); 19 | 20 | app.use(cookieParser()); 21 | app.setGlobalPrefix('api'); 22 | 23 | app.enableCors({ 24 | origin: '*', // Adjust as needed 25 | credentials: true, 26 | }); 27 | 28 | app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); 29 | 30 | await app.listen(process.env.PORT ?? 3000); 31 | } 32 | 33 | bootstrap(); 34 | -------------------------------------------------------------------------------- /src/payment/dto/create-payment.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; 2 | 3 | export class CreatePaymentDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | userId: string; 7 | 8 | @IsNotEmpty() 9 | @IsNumber() 10 | amount: number; 11 | 12 | @IsNotEmpty() 13 | @IsString() 14 | currency: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/payment/dto/update-payment.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreatePaymentDto } from './create-payment.dto'; 3 | 4 | export class UpdatePaymentDto extends PartialType(CreatePaymentDto) {} 5 | -------------------------------------------------------------------------------- /src/payment/models/payment.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { AbstractModel } from 'src/common'; 3 | 4 | @Schema({ versionKey: false, timestamps: true }) 5 | export class PaymentModel extends AbstractModel { 6 | @Prop({ required: true }) 7 | userId: string; 8 | 9 | @Prop({ required: true }) 10 | orderId: string; 11 | 12 | @Prop({ required: true }) 13 | amount: number; 14 | 15 | @Prop({ required: true }) 16 | currency: string; 17 | 18 | @Prop({ required: true }) 19 | status: string; // e.g., 'pending', 'paid', 'failed' 20 | 21 | @Prop({ required: false }) 22 | paymobTransactionId?: string; 23 | } 24 | 25 | export const PaymentSchema = SchemaFactory.createForClass(PaymentModel); 26 | -------------------------------------------------------------------------------- /src/payment/payment.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Body, 6 | Patch, 7 | Param, 8 | Delete, 9 | Req, 10 | HttpException, 11 | HttpStatus, 12 | } from '@nestjs/common'; 13 | import { PaymentService } from './payment.service'; 14 | import { CreatePaymentDto } from './dto/create-payment.dto'; 15 | import { UpdatePaymentDto } from './dto/update-payment.dto'; 16 | 17 | @Controller('payment') 18 | export class PaymentController { 19 | constructor(private readonly paymentService: PaymentService) { } 20 | 21 | @Post() 22 | async create(@Body() createPaymentDto: CreatePaymentDto) { 23 | return this.paymentService.create(createPaymentDto); 24 | } 25 | 26 | @Get() 27 | async findAll() { 28 | return this.paymentService.findAll(); 29 | } 30 | 31 | @Get(':id') 32 | async findOne(@Param('id') id: string) { 33 | return this.paymentService.findOne(+id); 34 | } 35 | 36 | @Patch(':id') 37 | async update(@Param('id') id: string, @Body() updatePaymentDto: UpdatePaymentDto) { 38 | return this.paymentService.update(+id, updatePaymentDto); 39 | } 40 | 41 | @Delete(':id') 42 | async remove(@Param('id') id: string) { 43 | return this.paymentService.remove(+id); 44 | } 45 | 46 | @Post('initialize') 47 | async initializePayment(@Body('userId') userId: string, @Body('amount') amount: number, @Body('currency') currency: string) { 48 | try { 49 | return await this.paymentService.initializePayment(userId, amount, currency); 50 | } catch (error) { 51 | throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); 52 | } 53 | } 54 | 55 | @Post('webhook') 56 | async handleWebhook(@Req() req: any) { 57 | try { 58 | return await this.paymentService.handleWebhook(req.body); 59 | } catch (error) { 60 | throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/payment/payment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PaymentService } from './payment.service'; 3 | import { PaymentController } from './payment.controller'; 4 | import { DatabaseModule } from 'src/common/database/database.module'; 5 | import { MongooseModule } from '@nestjs/mongoose'; 6 | import { PaymentModel, PaymentSchema } from './models/payment.schema'; 7 | 8 | @Module({ 9 | imports: [ 10 | DatabaseModule, 11 | MongooseModule.forFeature([{ name: PaymentModel.name, schema: PaymentSchema }]), 12 | ], 13 | controllers: [PaymentController], 14 | providers: [PaymentService], 15 | }) 16 | export class PaymentModule { } 17 | -------------------------------------------------------------------------------- /src/payment/payment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model, Connection } from 'mongoose'; 4 | import { PaymentModel } from './models/payment.schema'; 5 | import axios from 'axios'; 6 | import * as crypto from 'crypto'; 7 | import { CreatePaymentDto } from './dto/create-payment.dto'; 8 | import { UpdatePaymentDto } from './dto/update-payment.dto'; 9 | import { InjectConnection } from '@nestjs/mongoose'; 10 | 11 | @Injectable() 12 | export class PaymentService { 13 | private readonly logger = new Logger(PaymentService.name); 14 | 15 | constructor( 16 | @InjectModel(PaymentModel.name) private readonly paymentModel: Model, 17 | @InjectConnection() private readonly connection: Connection, 18 | ) { } 19 | 20 | async initializePayment(userId: string, amount: number, currency: string) { 21 | const session = await this.connection.startSession(); 22 | session.startTransaction(); 23 | 24 | try { 25 | const orderId = crypto.randomUUID(); 26 | 27 | // Save the order in the database 28 | const payment = await this.paymentModel.create( 29 | [{ userId, orderId, amount, currency, status: 'pending' }], 30 | { session }, 31 | ); 32 | 33 | // Initialize Paymob payment 34 | const authResponse = await axios.post('https://accept.paymob.com/api/auth/tokens', { 35 | api_key: process.env.PAYMOB_API_KEY, 36 | }); 37 | 38 | const token = authResponse.data.token; 39 | 40 | const orderResponse = await axios.post( 41 | 'https://accept.paymob.com/api/ecommerce/orders', 42 | { 43 | auth_token: token, 44 | delivery_needed: false, 45 | amount_cents: amount * 100, 46 | currency, 47 | items: [], 48 | }, 49 | ); 50 | 51 | const paymentKeyResponse = await axios.post( 52 | 'https://accept.paymob.com/api/acceptance/payment_keys', 53 | { 54 | auth_token: token, 55 | amount_cents: amount * 100, 56 | currency, 57 | order_id: orderResponse.data.id, 58 | billing_data: { 59 | first_name: 'John', 60 | last_name: 'Doe', 61 | email: 'john.doe@example.com', 62 | phone_number: '+201234567890', 63 | city: 'Cairo', 64 | country: 'EG', 65 | }, 66 | }, 67 | ); 68 | 69 | await session.commitTransaction(); 70 | session.endSession(); 71 | 72 | return { 73 | paymentUrl: `https://accept.paymob.com/api/acceptance/iframes/${process.env.PAYMOB_IFRAME_ID}?payment_token=${paymentKeyResponse.data.token}`, 74 | orderId, 75 | }; 76 | } catch (error) { 77 | await session.abortTransaction(); 78 | session.endSession(); 79 | this.logger.error('Error initializing payment', error); 80 | throw new InternalServerErrorException('Payment initialization failed'); 81 | } 82 | } 83 | 84 | async handleWebhook(data: any) { 85 | const session = await this.connection.startSession(); 86 | session.startTransaction(); 87 | 88 | try { 89 | const hmac = data.hmac; 90 | const calculatedHmac = this.calculateHmac(data); 91 | 92 | if (hmac !== calculatedHmac) { 93 | this.logger.error('Invalid HMAC'); 94 | throw new Error('Invalid HMAC'); 95 | } 96 | 97 | const payment = await this.paymentModel.findOne({ orderId: data.order.id }).session(session); 98 | if (!payment) { 99 | throw new Error('Order not found'); 100 | } 101 | 102 | // Ensure idempotency 103 | if (payment.status === 'paid' || payment.status === 'failed') { 104 | this.logger.warn('Webhook already processed for this order'); 105 | return payment; 106 | } 107 | 108 | payment.status = data.success ? 'paid' : 'failed'; 109 | payment.paymobTransactionId = data.id; 110 | await payment.save({ session }); 111 | 112 | await session.commitTransaction(); 113 | session.endSession(); 114 | 115 | return payment; 116 | } catch (error) { 117 | await session.abortTransaction(); 118 | session.endSession(); 119 | this.logger.error('Error handling webhook', error); 120 | throw new InternalServerErrorException('Webhook processing failed'); 121 | } 122 | } 123 | 124 | private calculateHmac(data: any): string { 125 | const keys = [ 126 | 'amount_cents', 127 | 'created_at', 128 | 'currency', 129 | 'error_occured', 130 | 'id', 131 | 'integration_id', 132 | 'is_3d_secure', 133 | 'is_auth', 134 | 'is_capture', 135 | 'is_refunded', 136 | 'is_standalone_payment', 137 | 'order', 138 | 'owner', 139 | 'pending', 140 | 'source_data', 141 | 'success', 142 | ]; 143 | const sortedKeys = keys.sort(); 144 | const concatenatedString = sortedKeys.map((key) => data[key]).join(''); 145 | return crypto.createHmac('sha512', process.env.PAYMOB_HMAC_SECRET).update(concatenatedString).digest('hex'); 146 | } 147 | 148 | async create(createPaymentDto: CreatePaymentDto) { 149 | const session = await this.connection.startSession(); 150 | session.startTransaction(); 151 | 152 | try { 153 | const orderId = crypto.randomUUID(); 154 | 155 | // Save the payment record in the database 156 | const payment = await this.paymentModel.create( 157 | [ 158 | { 159 | userId: createPaymentDto.userId, 160 | orderId, 161 | amount: createPaymentDto.amount, 162 | currency: createPaymentDto.currency, 163 | status: 'pending', 164 | }, 165 | ], 166 | { session }, 167 | ); 168 | 169 | await session.commitTransaction(); 170 | session.endSession(); 171 | 172 | return payment[0]; // Return the created payment 173 | } catch (error) { 174 | await session.abortTransaction(); 175 | session.endSession(); 176 | this.logger.error('Error creating payment', error); 177 | throw new InternalServerErrorException('Failed to create payment'); 178 | } 179 | } 180 | 181 | findAll() { 182 | return `This action returns all payment`; 183 | } 184 | 185 | findOne(id: number) { 186 | return `This action returns a #${id} payment`; 187 | } 188 | 189 | update(id: number, updatePaymentDto: UpdatePaymentDto) { 190 | return `This action updates a #${id} payment`; 191 | } 192 | 193 | remove(id: number) { 194 | return `This action removes a #${id} payment`; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/user/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class CreateUserDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | firstName: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | lastName: string; 11 | 12 | @IsNotEmpty() 13 | @IsEmail() 14 | email: string; 15 | 16 | @IsNotEmpty() 17 | @IsString() 18 | password: string; 19 | 20 | @IsOptional() 21 | @IsString() 22 | phone?: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/user/dto/get-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude } from 'class-transformer'; 2 | import { Role } from '../../auth/enum/roles.enum'; 3 | import { IsMongoId } from 'class-validator'; 4 | 5 | export class UserDto { 6 | @IsMongoId() 7 | id: string; 8 | 9 | firstName: string; 10 | 11 | lastName: string; 12 | 13 | email: string; 14 | 15 | role: Role; 16 | 17 | phone?: string; 18 | 19 | isVerified?: boolean; 20 | 21 | @Exclude() 22 | password: string; 23 | 24 | @Exclude() // Hide _id from the response -- > ITS BIN FORMAT 25 | _id?: any; 26 | 27 | constructor(user: Partial) { 28 | this.id = user._id?.toString() || user.id; // Convert _id to string 29 | Object.assign(this, user); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { OmitType, PartialType } from '@nestjs/mapped-types'; 2 | import { CreateUserDto } from './create-user.dto'; 3 | 4 | export class UpdateUserDto extends PartialType( 5 | OmitType(CreateUserDto, ['email', "phone"] as const) 6 | ) { } -------------------------------------------------------------------------------- /src/user/models/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Role } from 'src/auth/enum/roles.enum'; 3 | import { AbstractModel } from 'src/common'; 4 | 5 | @Schema({ versionKey: false }) 6 | export class UserModel extends AbstractModel { 7 | @Prop({ required: true, type: String }) 8 | firstName: string; 9 | 10 | @Prop({ required: true, type: String }) 11 | lastName: string; 12 | 13 | @Prop({ required: true, type: String }) 14 | email: string; 15 | 16 | @Prop({ required: true, type: String }) 17 | password: string; 18 | 19 | @Prop({ enum: Role, default: Role.USER }) 20 | role?: Role; 21 | 22 | @Prop({ required: false, type: String }) 23 | phone?: string; 24 | 25 | @Prop({ required: false, type: Boolean, default: false }) 26 | isVerified?: boolean; 27 | } 28 | // note schema is the actual mongodb doc structure but 29 | // the model is the class that represents the schema to interact with through the app 30 | 31 | export const UserSchema = SchemaFactory.createForClass(UserModel); 32 | -------------------------------------------------------------------------------- /src/user/repository/user.repo.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { AbstractRepo } from 'src/common'; 3 | import { UserModel } from '../models/user.schema'; 4 | import { InjectModel } from '@nestjs/mongoose'; 5 | import { Model } from 'mongoose'; 6 | 7 | @Injectable() 8 | export class UserRepo extends AbstractRepo { 9 | // to see why this is not a private go to the base class 10 | protected readonly logger = new Logger(UserRepo.name); 11 | 12 | constructor( 13 | @InjectModel(UserModel.name) private readonly userModel: Model, 14 | ) { 15 | super(userModel); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Patch, Param, UseGuards } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { CreateUserDto } from './dto/create-user.dto'; 4 | import { UpdateUserDto } from './dto/update-user.dto'; 5 | import { UserDto } from './dto/get-user.dto'; 6 | import { CurrentUser } from 'src/auth/decorators/get-curr-user.decorator'; 7 | import { JwtAuthGuard } from 'src/auth/guards/jwt.guard'; 8 | import { UserModel } from './models/user.schema'; 9 | import { Role } from 'src/auth/enum/roles.enum'; 10 | import { Roles } from 'src/auth/decorators/roles.decorator'; 11 | 12 | @Controller('user') 13 | export class UserController { 14 | constructor(private readonly userService: UserService) { } 15 | 16 | @UseGuards(JwtAuthGuard) 17 | @Get('profile/me') 18 | async profile(@CurrentUser() user: Partial) { 19 | return new UserDto(user); 20 | } 21 | 22 | @Roles(Role.ADMIN) 23 | @Post() 24 | 25 | async create(@Body() createUserDto: CreateUserDto) { 26 | const user = await this.userService.create(createUserDto); 27 | return new UserDto(user); 28 | } 29 | 30 | @Roles(Role.ADMIN) 31 | @Get() 32 | async findAll() { 33 | 34 | // make the pagination 35 | const users = await this.userService.findAll(); 36 | // filter every user 37 | return users.map((user) => new UserDto(user)); 38 | } 39 | 40 | 41 | @Roles(Role.ADMIN) 42 | @Get(':id') 43 | async findOne(@Param('id') id: string) { 44 | const user = await this.userService.findOne(id); 45 | return new UserDto(user); 46 | } 47 | 48 | 49 | 50 | @Roles(Role.ADMIN) 51 | @Patch(':id') 52 | async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { 53 | const updatedUser = await this.userService.update(id, updateUserDto); 54 | return new UserDto(updatedUser); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { UserController } from './user.controller'; 4 | import { DatabaseModule } from 'src/common/database/database.module'; 5 | import { UserRepo } from './repository/user.repo'; 6 | import { UserModel, UserSchema } from './models/user.schema'; 7 | 8 | @Module({ 9 | imports: [ 10 | DatabaseModule, 11 | DatabaseModule.forFeature([{ name: UserModel.name, schema: UserSchema }]), 12 | ], 13 | controllers: [UserController], 14 | providers: [UserService, UserRepo], 15 | exports: [UserService, UserRepo], 16 | }) 17 | export class UserModule {} 18 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { CreateUserDto } from './dto/create-user.dto'; 3 | import { UpdateUserDto } from './dto/update-user.dto'; 4 | import { UserRepo } from './repository/user.repo'; 5 | import { Types } from 'mongoose'; 6 | 7 | @Injectable() 8 | export class UserService { 9 | constructor(private readonly userRepo: UserRepo) { } 10 | 11 | create(createUserDto: CreateUserDto) { 12 | return this.userRepo.create(createUserDto); 13 | } 14 | 15 | findAll() { 16 | return this.userRepo.find({}); 17 | } 18 | 19 | findOne(_id: string) { 20 | 21 | // check if the id is valid at all ? 22 | // if not throw an error 23 | 24 | if (!Types.ObjectId.isValid(_id)) { 25 | throw new NotFoundException('Invalid id'); 26 | } 27 | 28 | return this.userRepo.findOne({ _id }); 29 | } 30 | 31 | update(_id: string, updateUserDto: UpdateUserDto) { 32 | return this.userRepo.findOneAndUpdate({ _id }, updateUserDto); 33 | } 34 | 35 | 36 | 37 | // delete(_id: number) { 38 | // // if the user is admin, then delete the user 39 | // // the admin cant delte himself 40 | 41 | // return this.userRepo.findOneAndDelete({ _id }); 42 | // } 43 | } 44 | -------------------------------------------------------------------------------- /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": "ES2021", 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 | } 21 | } 22 | --------------------------------------------------------------------------------