refactor: changed the project structure

This commit is contained in:
2026-07-28 20:17:25 +03:30
parent e462606416
commit 2cfda9ca45
65 changed files with 28 additions and 32 deletions
@@ -0,0 +1,13 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, Matches } from "class-validator";
export class LoginCredentialDto {
@ApiProperty({example: '09123456789'})
@IsString()
@Matches(/^09\d{9}$/)
phoneNumber: string;
@ApiProperty({example: 'Mystrongpassword@47'})
@IsString()
password: string;
}
+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;
}
+17
View File
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches, Length } from 'class-validator';
export class VerifyOtpDto {
@ApiProperty({
example: '09123456789',
})
@Matches(/^09\d{9}$/)
phoneNumber: string;
@ApiProperty({
example: '123456',
})
@IsString()
@Length(6, 6)
code: string;
}
+57
View File
@@ -0,0 +1,57 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { SendOtpDto } from './DTO/send-otp.dto';
import { LoginCredentialDto } from './DTO/login-credential.dto';
import { VerifyOtpDto } from './DTO/verify-otp.dto';
import { AuthGuard } from 'src/common/decorators/auth-guard.decorator';
import type { IRequest } from './interfaces/IRequest';
import { UserDec } from 'src/common/decorators/user.decorator';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('send-otp')
@ApiOperation({
summary: 'Send OTP to a phone number',
})
@ApiResponse({ status: 200, description: 'Sent Otp Successfully!' })
@ApiResponse({ status: 400, description: 'Bad Request from User!' })
async sendOtp(@Body() dto: SendOtpDto) {
return this.authService.sendOTP(dto.phoneNumber);
}
@Post('verify-otp')
@ApiOperation({
summary: 'Verify OTP.',
})
@ApiResponse({ status: 200, description: 'Verified Otp Successfully!' })
@ApiResponse({ status: 400, description: 'Bad Request from the user' })
async verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOTP(dto);
}
@Post('check-credential')
@ApiOperation({ summary: 'Check Login Credentials. If Valid Sends OTP' })
@ApiResponse({ status: 200, description: 'Valid Credentials!' })
@ApiResponse({ status: 400, description: 'Credentials Invalid!' })
async checkLoginCredential(@Body() dto: LoginCredentialDto) {
return this.authService.checkLoginCredentials(dto);
}
@Post('verify-login')
@ApiOperation({ summary: 'verifies OTP and then login' })
@ApiResponse({ status: 200, description: 'User Logged in successfully!' })
@ApiResponse({ status: 400, description: 'Bad Request From The User' })
async verifyOtpAndLogin(@Body() verifyOtpDto: VerifyOtpDto) {
return this.authService.verifyOtpAndLogin(verifyOtpDto);
}
@AuthGuard()
@Get('profile')
profile(@Req() req: IRequest, @UserDec('id') userId: string) {
return req.user;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UtilsModule } from '../utils/utils.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OTP } from './entities/otp.entity';
import { OtpRepository } from './repositories/otp.repository';
import { UsersModule } from 'src/modules/users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
TypeOrmModule.forFeature([OTP]),
PassportModule,
JwtModule.register({}),
UtilsModule,
UsersModule,
],
controllers: [AuthController],
providers: [AuthService, OtpRepository, JwtStrategy],
exports: [JwtModule, PassportModule]
})
export class AuthModule {}
+168
View File
@@ -0,0 +1,168 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { ISmsConfigs } from 'src/config/sms.config';
import { SMS_CONFIG } from '../utils/constants';
import { SmsService } from '../utils/providers/sms.service';
import { OtpRepository } from './repositories/otp.repository';
import { OtpStatus } from './enums/otp-status.enum';
import { VerifyOtpDto } from './DTO/verify-otp.dto';
import { OTPMessages, UserMessages } from 'src/common/enums/messages.enum';
import { LoginCredentialDto } from './DTO/login-credential.dto';
import { UsersService } from 'src/modules/users/users.service';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
import ms from 'ms';
@Injectable()
export class AuthService {
constructor(
@Inject(SMS_CONFIG) private smsConfig: ISmsConfigs,
private readonly smsService: SmsService,
private readonly configService: ConfigService,
private readonly OtpRepository: OtpRepository,
private readonly userService: UsersService,
private readonly jwtService: JwtService,
) {}
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: new Date(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;
}
}
async verifyOTP(dto: VerifyOtpDto) {
const otp = await this.OtpRepository.findOne({
where: { phoneNumber: dto.phoneNumber, status: OtpStatus.PENDING },
order: {
createdAt: 'DESC',
},
});
if (!otp) {
throw new BadRequestException(OTPMessages.OTP_NOT_FOUND);
}
if (otp.expirationDate < new Date()) {
otp.status = OtpStatus.EXPIRED;
await this.OtpRepository.save(otp);
throw new BadRequestException(OTPMessages.OTP_EXPIRED);
}
if (otp.code !== dto.code) {
otp.numberOfTries++;
if (otp.numberOfTries >= 3) {
otp.status = OtpStatus.EXPIRED;
await this.OtpRepository.save(otp);
throw new BadRequestException(OTPMessages.OTP_TOO_MANY_TRIES);
}
await this.OtpRepository.save(otp);
throw new BadRequestException(OTPMessages.OTP_INVALID);
}
otp.status = OtpStatus.VERIFIED;
await this.OtpRepository.save(otp);
}
private generateOtp(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
async checkLoginCredentials(credential: LoginCredentialDto): Promise<void> {
const { phoneNumber, password } = credential;
const user = await this.userService.findOneByPhoneOrFail(phoneNumber);
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new BadRequestException(UserMessages.USER_PASSWORD_INVALID);
}
await this.sendOTP(phoneNumber);
}
async verifyOtpAndLogin(dto: VerifyOtpDto) {
await this.verifyOTP(dto);
const user = await this.userService.findUserWithRolesByPhoneOrFail(
dto.phoneNumber,
);
const roles = user.roles.map((r) => r.role);
const payload = {
id: user.id,
phone: user.phone,
roles,
};
const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: this.configService.getOrThrow<string>(
'JWT_ACCESS_EXPIRES',
) as ms.StringValue,
});
const refreshToken = await this.jwtService.signAsync(payload, {
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.getOrThrow<string>(
'JWT_REFRESH_EXPIRES',
) as ms.StringValue,
});
await this.userService.updateRefreshToken(user.id, refreshToken);
return {
user: {
userId: user.id,
username: user.firstName + ' ' + user.lastName,
userPhone: user.phone,
userEmail: user.email,
},
accessToken,
refreshToken,
};
}
}
+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;
}
@@ -0,0 +1,5 @@
export enum OtpStatus {
PENDING = 'PENDING',
VERIFIED = 'VERIFIED',
EXPIRED = 'EXPIRED'
}
+33
View File
@@ -0,0 +1,33 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { SKIP_AUTH_KEY } from 'src/common/decorators/skip-auth.decorator';
import { AuthMessages } from 'src/common/enums/messages.enum';
import { JWT_STRATEGY_NAME } from 'src/constants';
@Injectable()
export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY_NAME) {
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const skipAuth = this.reflector.getAllAndOverride<boolean>(SKIP_AUTH_KEY, [
context.getHandler(),
context.getClass(),
]);
if (skipAuth) return true;
return super.canActivate(context);
}
handleRequest(err: unknown, user: any, _info: unknown) {
if (err || !user)
throw new UnauthorizedException(AuthMessages.TOKEN_EXPIRED_OR_INVALID);
return user;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { ITokenPayload } from "./IToken-payload";
import { IUserIpAndHeaders } from "./IUserIpAndHeader";
export interface IRequest {
user?: ITokenPayload,
ip_header?: IUserIpAndHeaders
}
@@ -0,0 +1,7 @@
import { RoleType } from "src/modules/users/enums/user-role.enum";
export interface ITokenPayload {
id: string;
phone: string;
roles: RoleType[];
}
@@ -0,0 +1,6 @@
export interface IUserIpAndHeaders {
ip: string;
headers: Record<string, string>;
userId: string;
}
@@ -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,
);
}
}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { JWT_STRATEGY_NAME } from 'src/constants';
import { ITokenPayload } from '../interfaces/IToken-payload';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY_NAME) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_ACCESS_SECRET'),
});
}
async validate(payload: ITokenPayload) {
return {
id: payload.id,
phone: payload.phone,
roles: payload.roles
};
}
}