feat: added smsService and sendOtp

This commit is contained in:
2026-07-23 18:42:12 +03:30
parent 7c4c83e5d8
commit f380b1267d
20 changed files with 369 additions and 14 deletions
+5 -1
View File
@@ -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 {}
+12
View File
@@ -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;
}
+22
View File
@@ -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);
}
}
+15
View File
@@ -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 {}
+62
View File
@@ -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();
}
}
+23
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
export enum OtpStatus {
PENDING = 'PENDING',
VERIFIED = 'VERIFIED',
EXPIRED = 'EXPIRED'
}
+13
View File
@@ -0,0 +1,13 @@
import { Repository } from 'typeorm';
import { OTP } from '../entities/otp.entity';
import { InjectRepository } from '@nestjs/typeorm';
export class OtpRepository extends Repository<OTP> {
constructor(@InjectRepository(OTP) otpRepository: Repository<OTP>) {
super(
otpRepository.target,
otpRepository.manager,
otpRepository.queryRunner,
);
}
}
+9 -1
View File
@@ -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()
});
+22
View File
@@ -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<ISmsConfigs> = {
provide: SMS_CONFIG,
inject: [ConfigService],
useFactory(configService: ConfigService) {
return {
API_URL: configService.getOrThrow<string>("SMS_API_URL"),
API_KEY: configService.getOrThrow<string>("SMS_API_KEY"),
SMS_PATTERN_OTP: configService.getOrThrow<string>("SMS_PATTERN_OTP")
};
},
};
export interface ISmsConfigs {
API_URL: string;
API_KEY: string;
SMS_PATTERN_OTP: string;
}
@@ -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";
@@ -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<User> {
return this.userService.signupUser(body);
return this.userService.createUser(body);
}
}
+3 -3
View File
@@ -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';
@@ -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<User> {
async createUser(userDto: CreateUserDto): Promise<User> {
const existingUser = await this.userRepository.findOne({
where: [
{
+1
View File
@@ -0,0 +1 @@
export const SMS_CONFIG = "SMS_CONFIG";
+24
View File
@@ -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;
+62
View File
@@ -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<SmsSendResponse> {
try {
const { data } = await firstValueFrom(
this.httpService
.post<SmsSendResponse>(
`${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.');
}
}
}
+10
View File
@@ -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 {}