diff --git a/package.json b/package.json index 2dc24d6..3ef60c8 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,14 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.1.28", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.28", "@nestjs/platform-express": "^11.1.28", "@nestjs/swagger": "^11.4.6", "@nestjs/typeorm": "^11.0.3", + "axios": "^1.18.1", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9643c98..f898572 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.18.1)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.1.28 version: 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -26,6 +29,9 @@ importers: '@nestjs/typeorm': specifier: ^11.0.3 version: 11.0.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@1.1.0(pg@8.22.0)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.9.3))) + axios: + specifier: ^1.18.1 + version: 1.18.1 bcrypt: specifier: ^6.0.0 version: 6.0.0 @@ -692,6 +698,13 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nestjs/axios@4.0.1': + resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + '@nestjs/cli@11.0.24': resolution: {integrity: sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==} engines: {node: '>= 20.11'} @@ -1214,6 +1227,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1310,6 +1327,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + babel-jest@30.4.1: resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1857,6 +1877,15 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1993,6 +2022,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2663,6 +2696,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4029,6 +4066,12 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@nestjs/axios@4.0.1(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.18.1)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.18.1 + rxjs: 7.8.2 + '@nestjs/cli@11.0.24(@types/node@22.20.1)(prettier@3.9.5)': dependencies: '@angular-devkit/core': 19.2.27(chokidar@4.0.3) @@ -4597,6 +4640,12 @@ snapshots: acorn@8.17.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -4678,6 +4727,16 @@ snapshots: asynckit@0.4.0: {} + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + babel-jest@30.4.1(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -5253,6 +5312,8 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.16.0: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -5405,6 +5466,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.7.3: @@ -6182,6 +6250,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + punycode@2.3.1: {} pure-rand@7.0.1: {} diff --git a/src/app.module.ts b/src/app.module.ts index e5a8b82..a742a29 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,8 @@ import Joi from 'joi'; import { validationSchema } from './config/config.validation'; import { UsersModule } from './users/users.module'; import { databaseConfig } from './config/typeorm.config'; +import { HttpModule } from '@nestjs/axios'; +import { AuthModule } from './auth/auth.module'; @Module({ imports: [ @@ -13,7 +15,9 @@ import { databaseConfig } from './config/typeorm.config'; validationSchema }), TypeOrmModule.forRootAsync(databaseConfig()), - UsersModule + HttpModule.register({global: true, timeout: 10000, headers: {"Content-Type": "application/json"}}), + UsersModule, + AuthModule ], }) export class AppModule {} diff --git a/src/auth/DTO/send-otp.dto.ts b/src/auth/DTO/send-otp.dto.ts new file mode 100644 index 0000000..4d3e945 --- /dev/null +++ b/src/auth/DTO/send-otp.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Matches } from 'class-validator'; + +export class SendOtpDto { + @ApiProperty({ + example: '09123456789', + }) + @Matches(/^09\d{9}$/, { + message: 'Phone number must be a valid Iranian mobile number.', + }) + phoneNumber!: string; +} \ No newline at end of file diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..59b6bdd --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,22 @@ +import { Body, Controller, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { AuthService } from "./auth.service"; +import { SendOtpDto } from "./DTO/send-otp.dto"; + +@ApiTags('Authentication') +@Controller('auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + ) {} + + @Post('send-otp') + @ApiOperation({ + summary: 'Send OTP to a phone number', + }) + async sendOtp( + @Body() dto: SendOtpDto, + ) { + return this.authService.sendOTP(dto.phoneNumber); + } +} \ No newline at end of file diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..e22f302 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { UtilsModule } from 'src/utils/utils.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from 'src/users/entities/user.entity'; +import { OTP } from './entities/otp.entity'; +import { OtpRepository } from './repositories/otp.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([User, OTP]), UtilsModule], + controllers: [AuthController], + providers: [AuthService, OtpRepository], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..92693a0 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,62 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { ISmsConfigs } from 'src/config/sms.config'; +import { SMS_CONFIG } from 'src/utils/constants'; +import { SmsService } from 'src/utils/providers/sms.service'; +import { OtpRepository } from './repositories/otp.repository'; +import { OtpStatus } from './enums/otp-status.enum'; + +@Injectable() +export class AuthService { + constructor( + @Inject(SMS_CONFIG) private smsConfig: ISmsConfigs, + private readonly smsService: SmsService, + private readonly configService: ConfigService, + private readonly OtpRepository: OtpRepository, + ) {} + + async sendOTP(phone: string) { + const otp = this.generateOtp(); + + try { + await this.smsService.sendSms({ + Mobile: phone, + TemplateId: this.smsConfig.SMS_PATTERN_OTP, + Parameters: [ + { + name: 'code', + value: otp, + }, + ], + }); + + await this.OtpRepository.update( + { phoneNumber: phone, status: OtpStatus.PENDING }, + { status: OtpStatus.EXPIRED }, + ); + + await this.OtpRepository.save( + this.OtpRepository.create({ + phoneNumber: phone, + code: otp, + expirationDate: Date.now() + 2 * 60 * 1000, + status: OtpStatus.PENDING, + numberOfTries: 0 + }) + ); + + return {otp}; + + } catch (error) { + if (this.configService.get('NODE_ENV') !== 'production') { + return { otp }; + } + + throw error; + } + } + + private generateOtp(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); + } +} diff --git a/src/auth/entities/otp.entity.ts b/src/auth/entities/otp.entity.ts new file mode 100644 index 0000000..104a8cd --- /dev/null +++ b/src/auth/entities/otp.entity.ts @@ -0,0 +1,23 @@ +import { Column, Entity } from 'typeorm'; +import { BaseEntity } from 'src/common/entities/base.entity'; +import { OtpStatus } from '../enums/otp-status.enum'; + +@Entity('otps') +export class OTP extends BaseEntity { + @Column({ type: 'varchar', length: 6 }) + code: string; + + @Column({ type: 'timestamptz' }) + expirationDate: Date; + + @Column({type: 'enum', enum: OtpStatus, default: OtpStatus.PENDING}) + status: OtpStatus; + + @Column({ type: 'smallint', default: 0 }) + numberOfTries: number; + + @Column({ + length: 11, + }) + phoneNumber: string; +} diff --git a/src/auth/enums/otp-status.enum.ts b/src/auth/enums/otp-status.enum.ts new file mode 100644 index 0000000..b4780c2 --- /dev/null +++ b/src/auth/enums/otp-status.enum.ts @@ -0,0 +1,5 @@ +export enum OtpStatus { + PENDING = 'PENDING', + VERIFIED = 'VERIFIED', + EXPIRED = 'EXPIRED' +} \ No newline at end of file diff --git a/src/auth/repositories/otp.repository.ts b/src/auth/repositories/otp.repository.ts new file mode 100644 index 0000000..08c2f87 --- /dev/null +++ b/src/auth/repositories/otp.repository.ts @@ -0,0 +1,13 @@ +import { Repository } from 'typeorm'; +import { OTP } from '../entities/otp.entity'; +import { InjectRepository } from '@nestjs/typeorm'; + +export class OtpRepository extends Repository { + constructor(@InjectRepository(OTP) otpRepository: Repository) { + super( + otpRepository.target, + otpRepository.manager, + otpRepository.queryRunner, + ); + } +} diff --git a/src/config/config.validation.ts b/src/config/config.validation.ts index ffb022c..34596e4 100644 --- a/src/config/config.validation.ts +++ b/src/config/config.validation.ts @@ -3,9 +3,17 @@ import * as Joi from 'joi'; export const validationSchema = Joi.object({ APP_PORT: Joi.number().required(), + // DB Config DB_HOST: Joi.string().required(), DB_PORT: Joi.number().required(), DB_USER: Joi.string().required(), DB_PASSWORD: Joi.string().required(), DB_NAME: Joi.string().required(), -}); + + // SMS Config + SMS_API_URL: Joi.string().required(), + SMS_API_KEY: Joi.string().required(), + SMS_PATTERN_OTP: Joi.string().required(), + + NODE_ENV: Joi.string().required() +}); \ No newline at end of file diff --git a/src/config/sms.config.ts b/src/config/sms.config.ts new file mode 100644 index 0000000..c168687 --- /dev/null +++ b/src/config/sms.config.ts @@ -0,0 +1,22 @@ +import { FactoryProvider } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { SMS_CONFIG } from "src/utils/constants"; + +export const smsConfigsProvider: FactoryProvider = { + provide: SMS_CONFIG, + inject: [ConfigService], + useFactory(configService: ConfigService) { + return { + API_URL: configService.getOrThrow("SMS_API_URL"), + API_KEY: configService.getOrThrow("SMS_API_KEY"), + SMS_PATTERN_OTP: configService.getOrThrow("SMS_PATTERN_OTP") + }; + }, +}; + +export interface ISmsConfigs { + API_URL: string; + API_KEY: string; + SMS_PATTERN_OTP: string; +} diff --git a/src/users/repositories/user.repository.ts b/src/users/user.repository.ts similarity index 88% rename from src/users/repositories/user.repository.ts rename to src/users/user.repository.ts index b4a8e09..e83ab3d 100644 --- a/src/users/repositories/user.repository.ts +++ b/src/users/user.repository.ts @@ -1,5 +1,5 @@ import { Repository } from "typeorm"; -import { User } from "../entities/user.entity"; +import { User } from "./entities/user.entity"; import { InjectRepository } from "@nestjs/typeorm"; import { Injectable } from "@nestjs/common"; diff --git a/src/users/controllers/users.controller.ts b/src/users/users.controller.ts similarity index 71% rename from src/users/controllers/users.controller.ts rename to src/users/users.controller.ts index c64425b..9d0e611 100644 --- a/src/users/controllers/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Get, Post } from '@nestjs/common'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { CreateUserDto } from '../DTO/create-user.dto'; -import { User } from '../entities/user.entity'; -import { UsersService } from '../providers/users.service'; +import { CreateUserDto } from './DTO/create-user.dto'; +import { User } from './entities/user.entity'; +import { UsersService } from './users.service'; @Controller('users') export class UsersController { @@ -16,6 +16,6 @@ export class UsersController { @ApiResponse({status: 201, description: 'User created successfully!'}) @ApiResponse({status: 400, description: 'Bad Request from User!'}) signupUser(@Body() body: CreateUserDto): Promise { - return this.userService.signupUser(body); + return this.userService.createUser(body); } } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 077f0f1..1323e5a 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; -import { UsersController } from './controllers/users.controller'; -import { UsersService } from './providers/users.service'; -import { UserRepository } from './repositories/user.repository'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { UserRepository } from './user.repository'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; diff --git a/src/users/providers/users.service.ts b/src/users/users.service.ts similarity index 77% rename from src/users/providers/users.service.ts rename to src/users/users.service.ts index b9d4d08..950544c 100644 --- a/src/users/providers/users.service.ts +++ b/src/users/users.service.ts @@ -1,15 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { UserRepository } from '../repositories/user.repository'; -import { User } from '../entities/user.entity'; +import { UserRepository } from './user.repository'; +import { User } from './entities/user.entity'; import * as bcrypt from 'bcrypt'; -import { CreateUserDto } from '../DTO/create-user.dto'; +import { CreateUserDto } from './DTO/create-user.dto'; import { UserMessages } from 'src/common/enums/messages.enum'; @Injectable() export class UsersService { constructor(private readonly userRepository: UserRepository) {} - async signupUser(userDto: CreateUserDto): Promise { + async createUser(userDto: CreateUserDto): Promise { const existingUser = await this.userRepository.findOne({ where: [ { diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..9ffb806 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1 @@ +export const SMS_CONFIG = "SMS_CONFIG"; \ No newline at end of file diff --git a/src/utils/interfaces/ISms.ts b/src/utils/interfaces/ISms.ts new file mode 100644 index 0000000..4fde89b --- /dev/null +++ b/src/utils/interfaces/ISms.ts @@ -0,0 +1,24 @@ +export interface SmsParameter { + name: SmsTemplateParameter; + value: string; +} + +export interface SmsRequest { + Mobile: string; + TemplateId: string; + Parameters: SmsParameter[]; +} + +export interface SmsResponse { + status: number; + message: string; +} + +export interface SmsSendResponse extends SmsResponse { + data: { + MessageId: number; + Cost: number; + }; +} + +export type SmsTemplateParameter = string; diff --git a/src/utils/providers/sms.service.ts b/src/utils/providers/sms.service.ts new file mode 100644 index 0000000..0d969f9 --- /dev/null +++ b/src/utils/providers/sms.service.ts @@ -0,0 +1,62 @@ +import { + BadGatewayException, + Inject, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { SMS_CONFIG } from '../constants'; +import type { ISmsConfigs } from 'src/config/sms.config'; +import { catchError, firstValueFrom } from 'rxjs'; +import { HttpService } from '@nestjs/axios'; +import { AxiosError } from 'axios'; +import { SmsRequest, SmsSendResponse } from '../interfaces/ISms'; + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor( + @Inject(SMS_CONFIG) private smsConfigs: ISmsConfigs, + private readonly httpService: HttpService, + ) {} + + async sendSms(body: SmsRequest): Promise { + try { + const { data } = await firstValueFrom( + this.httpService + .post( + `${this.smsConfigs.API_URL}/send/verify`, + body, + { + headers: { + 'X-API-KEY': this.smsConfigs.API_KEY, + }, + timeout: 5000, + }, + ) + .pipe( + catchError((error: AxiosError) => { + this.logger.error( + 'Error while calling SMS provider.', + error.stack, + ); + + throw new InternalServerErrorException( + 'Failed to communicate with SMS provider.', + ); + }), + ), + ); + + return data; + } catch (error) { + this.logger.error( + 'Failed to send SMS.', + error instanceof Error ? error.stack : undefined, + ); + + throw new InternalServerErrorException('Failed to send SMS.'); + } + } +} diff --git a/src/utils/utils.module.ts b/src/utils/utils.module.ts new file mode 100644 index 0000000..485d116 --- /dev/null +++ b/src/utils/utils.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SmsService } from './providers/sms.service'; +import { smsConfigsProvider } from 'src/config/sms.config'; +import { SMS_CONFIG } from './constants'; + +@Module({ + providers: [SmsService, smsConfigsProvider], + exports: [SMS_CONFIG, SmsService], +}) +export class UtilsModule {}