Refactored the whole app based on the requirements shared

This commit is contained in:
Stephanos A
2026-05-21 08:48:28 +03:00
parent 2dc3da9e74
commit 51bc906792
84 changed files with 6880 additions and 12659 deletions

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
@ApiTags('Auth')
@Controller('auth')
@@ -9,10 +9,73 @@ export class AuthController {
constructor(private service: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register new user' })
@ApiOperation({
summary: 'Register new passenger account',
description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.'
})
@ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' })
@ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' })
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto })
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
@Post('login')
@ApiOperation({ summary: 'Login and get JWT' })
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Login with email and password',
description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.'
})
@ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' })
@ApiResponse({ status: 401, description: 'Invalid credentials or account locked' })
@ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' })
@ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) { return this.service.login(dto); }
@Post('otp/request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request OTP verification code',
description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.'
})
@ApiResponse({ status: 200, description: 'OTP sent successfully to email' })
@ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' })
@ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' })
@ApiBody({ type: RequestOtpDto })
requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); }
@Post('otp/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Verify OTP code',
description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.'
})
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' })
@ApiBody({ type: VerifyOtpDto })
verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); }
@Post('password/reset-request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request password reset link',
description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
@ApiResponse({ status: 404, description: 'Email not found' })
@ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' })
@ApiBody({ type: RequestPasswordResetDto })
requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); }
@Post('password/reset')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Reset password with token',
description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' })
@ApiResponse({ status: 404, description: 'User not found' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
}

View File

@@ -1,14 +1,152 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'Kelemu Ketsela' }) @IsString() fullName: string;
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: '+251912345678' }) @IsString() phone: string;
@ApiProperty({ example: 'password123', minLength: 8 }) @IsString() @MinLength(8) password: string;
@ApiProperty({
description: 'Full name of the passenger',
example: 'Kelemu Ketsela',
minLength: 2,
maxLength: 100
})
@IsString()
fullName: string;
@ApiProperty({
description: 'Email address (must be unique)',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Phone number with country code',
example: '+251912345678',
pattern: '^\\+[1-9]\\d{1,14}$'
})
@IsString()
phone: string;
@ApiProperty({
description: 'Password (minimum 8 characters)',
example: 'SecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({
description: 'Nationality of the passenger',
example: 'Ethiopian'
})
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({
description: 'National ID number',
example: 'ET123456789'
})
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({
description: 'Passport number for international travelers',
example: 'P1234567'
})
@IsOptional()
@IsString()
passportNumber?: string;
}
export class LoginDto {
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: 'password123' }) @IsString() password: string;
@ApiProperty({
description: 'Registered email address',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Account password',
example: 'password123',
format: 'password'
})
@IsString()
password: string;
}
export class RequestOtpDto {
@ApiProperty({
description: 'Email address to send OTP',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class VerifyOtpDto {
@ApiProperty({
description: 'Email address',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: '6-digit OTP code',
example: '123456',
minLength: 6,
maxLength: 6
})
@IsString()
code: string;
@ApiProperty({
description: 'Purpose of OTP verification',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class RequestPasswordResetDto {
@ApiProperty({
description: 'Email address of the account',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
}
export class ResetPasswordDto {
@ApiProperty({
description: 'Password reset token received via email',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
@IsString()
token: string;
@ApiProperty({
description: 'New password (minimum 8 characters)',
example: 'NewSecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
newPassword: string;
}

View File

@@ -1,8 +1,9 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
@Injectable()
export class AuthService {
@@ -15,28 +16,115 @@ export class AuthService {
if (exists) throw new ConflictException('Email or phone already registered');
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: { fullName: dto.fullName, email: dto.email, phone: dto.phone, passwordHash },
data: {
fullName: dto.fullName,
email: dto.email,
phone: dto.phone,
passwordHash,
nationality: dto.nationality,
nationalId: dto.nationalId,
passportNumber: dto.passportNumber
},
});
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.userPreferences.create({ data: { userId: user.id } });
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
return this.signToken(user.id, user.email, user.role, passenger.id);
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
include: { passenger: true },
include: { passenger: true, agent: true },
});
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
if (!user) throw new UnauthorizedException('Invalid credentials');
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`);
}
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: { increment: 1 },
lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null
}
});
throw new UnauthorizedException('Invalid credentials');
}
return this.signToken(user.id, user.email, user.role, user.passenger?.id);
await this.prisma.user.update({
where: { id: user.id },
data: { failedLoginAttempts: 0, lockedUntil: null }
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
return this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
}
private signToken(userId: string, email: string, role: string, passengerId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId });
return { token, user: { id: userId, email, role, passengerId } };
async requestOtp(dto: RequestOtpDto) {
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this.prisma.otpCode.create({
data: { email: dto.email, code, purpose: dto.purpose, expiresAt }
});
console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`);
return { sent: true, expiresIn: 600 };
}
async verifyOtp(dto: VerifyOtpDto) {
const otp = await this.prisma.otpCode.findFirst({
where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } },
orderBy: { createdAt: 'desc' }
});
if (!otp) throw new BadRequestException('Invalid or expired OTP');
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } });
return { verified: true };
}
async requestPasswordReset(dto: RequestPasswordResetDto) {
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
if (!user) return { sent: true };
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await this.prisma.passwordResetToken.create({
data: { userId: user.id, token, expiresAt }
});
console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`);
return { sent: true };
}
async resetPassword(dto: ResetPasswordDto) {
const resetToken = await this.prisma.passwordResetToken.findUnique({
where: { token: dto.token }
});
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null }
});
await this.prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true }
});
await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null);
return { reset: true };
}
private signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId, agentId });
return { token, user: { id: userId, email, role, passengerId, agentId } };
}
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
await this.prisma.auditLog.create({
data: { userId, action, entityType, entityId, oldData, newData }
});
}
}