// src/modules/auth/auth.service.ts // Updated version with email integration import { Injectable, UnauthorizedException, BadRequestException, ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import { User, UserStatus } from './entities/user.entity'; import { Role, RoleName } from './entities/role.entity'; import { Session } from './entities/session.entity'; import { PasswordReset } from './entities/password-reset.entity'; import { RegisterRequestDto } from './dto/register-request.dto'; import { LoginRequestDto } from './dto/login-request.dto'; import { LoginResponseDto } from './dto/auth-response.dto'; import { UserDto } from './dto/user.dto'; import { plainToClass } from 'class-transformer'; import { EmailService } from '../email/email.service'; import { NotificationsService } from '../notifications/services/notifications.service'; import { NotificationPriority, NotificationType } from '../notifications/enums'; @Injectable() export class AuthService { constructor( @InjectRepository(User) private userRepository: Repository, @InjectRepository(Role) private roleRepository: Repository, @InjectRepository(Session) private sessionRepository: Repository, @InjectRepository(PasswordReset) private passwordResetRepository: Repository, private jwtService: JwtService, private configService: ConfigService, private emailService: EmailService, private notificationsService: NotificationsService, ) {} async register( registerDto: RegisterRequestDto, request: any, ): Promise<{ message: string; user: UserDto }> { const existingUser = await this.userRepository.findOne({ where: { email: registerDto.email }, withDeleted: true, }); if (existingUser) { throw new ConflictException('Email already registered'); } const roleName = registerDto.role || RoleName.STUDENT; const role = await this.roleRepository.findOne({ where: { roleName }, }); if (!role) { throw new BadRequestException('Invalid role'); } const user = this.userRepository.create({ email: registerDto.email, passwordHash: registerDto.password, firstName: registerDto.firstName, lastName: registerDto.lastName, phone: registerDto.phone, status: UserStatus.ACTIVE, emailVerified: true, roles: [role], }); await this.userRepository.save(user); return { message: 'Registration successful! Your account is now active.', user: this.transformUserToDto(user), }; } async login( loginDto: LoginRequestDto, request: any, ): Promise { const user = await this.userRepository.findOne({ where: { email: loginDto.email }, relations: ['roles'], }); if (!user) { throw new UnauthorizedException('Invalid credentials'); } const isPasswordValid = await user.validatePassword(loginDto.password); if (!isPasswordValid) { throw new UnauthorizedException('Invalid credentials'); } if (user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('Account is not active'); } user.lastLoginAt = new Date(); await this.userRepository.save(user); return this.generateAuthResponse(user, request, loginDto.rememberMe); } async refreshToken( refreshToken: string, request: any, ): Promise { try { const payload = this.jwtService.verify(refreshToken, { secret: this.configService.get('JWT_REFRESH_SECRET'), }); const user = await this.userRepository.findOne({ where: { userId: payload.sub }, relations: ['roles'], }); if (!user) { throw new UnauthorizedException('User not found'); } const session = await this.sessionRepository.findOne({ where: { userId: user.userId, sessionToken: refreshToken, }, }); if (!session) { throw new UnauthorizedException('Invalid session'); } if (new Date() > session.expiresAt) { await this.sessionRepository.remove(session); throw new UnauthorizedException('Session expired'); } await this.sessionRepository.remove(session); return this.generateAuthResponse(user, request, session.rememberMe); } catch (error) { throw new UnauthorizedException('Invalid refresh token'); } } async logout(userId: number, sessionToken: string): Promise { await this.sessionRepository.delete({ userId, sessionToken, }); } async forgotPassword(email: string): Promise { const user = await this.userRepository.findOne({ where: { email } }); if (!user) { // Don't reveal if email exists (security best practice) return; } // Invalidate any existing unused reset tokens for this user await this.passwordResetRepository.update( { userId: user.userId, used: false }, { used: true, usedAt: new Date() }, ); // Generate reset token const resetToken = crypto.randomBytes(32).toString('hex'); const hashedToken = crypto .createHash('sha256') .update(resetToken) .digest('hex'); // Save reset token const passwordReset = this.passwordResetRepository.create({ userId: user.userId, resetToken: hashedToken, expiresAt: new Date(Date.now() + 3600000), // 1 hour used: false, }); await this.passwordResetRepository.save(passwordReset); // Send reset email with the plain token try { await this.emailService.sendPasswordResetEmail( user.email, resetToken, user.firstName, ); } catch (error) { console.error('Failed to send password reset email:', error); throw new BadRequestException('Failed to send password reset email'); } } async resetPassword(token: string, newPassword: string): Promise { const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); const passwordReset = await this.passwordResetRepository.findOne({ where: { resetToken: hashedToken, used: false }, relations: ['user'], }); if (!passwordReset) { throw new BadRequestException('Invalid or expired reset token'); } if (new Date() > passwordReset.expiresAt) { throw new BadRequestException('Reset token has expired'); } // Update password passwordReset.user.passwordHash = newPassword; await this.userRepository.save(passwordReset.user); // Mark token as used passwordReset.used = true; passwordReset.usedAt = new Date(); await this.passwordResetRepository.save(passwordReset); // Invalidate all user sessions await this.sessionRepository.delete({ userId: passwordReset.user.userId }); // Send confirmation email try { await this.emailService.sendPasswordChangedNotification( passwordReset.user.email, passwordReset.user.firstName, ); } catch (error) { console.error('Failed to send password changed notification:', error); // Don't throw error, password was already changed successfully } await this.notificationsService.createNotification({ userId: passwordReset.user.userId, notificationType: NotificationType.SYSTEM, title: 'Password Changed', body: 'Your password was changed successfully. If this was not you, contact support immediately.', relatedEntityType: 'user', relatedEntityId: passwordReset.user.userId, priority: NotificationPriority.HIGH, actionUrl: '/profile/security', }); } async getCurrentUser(userId: number): Promise { const user = await this.userRepository.findOne({ where: { userId }, relations: ['roles'], }); if (!user) { throw new UnauthorizedException('User not found'); } return this.transformUserToDto(user); } private async generateAuthResponse( user: User, request: any, rememberMe: boolean = false, ): Promise { const payload = { sub: user.userId, email: user.email, roles: user.roles.map((role) => role.roleName), }; const accessTokenExpiration = rememberMe ? '24h' : '1h'; const refreshTokenExpiration = rememberMe ? '30d' : '7d'; const accessToken = this.jwtService.sign(payload, { secret: this.configService.get('JWT_SECRET'), expiresIn: accessTokenExpiration, }); const refreshToken = this.jwtService.sign(payload, { secret: this.configService.get('JWT_REFRESH_SECRET'), expiresIn: refreshTokenExpiration, }); const sessionExpirationMs = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000; const session = this.sessionRepository.create({ userId: user.userId, sessionToken: refreshToken, ipAddress: request.ip, userAgent: request.headers['user-agent'], deviceType: this.detectDeviceType(request.headers['user-agent']), expiresAt: new Date(Date.now() + sessionExpirationMs), rememberMe: rememberMe, }); try { await this.sessionRepository.save(session); } catch (error) { if (this.isDeviceTypeTruncationError(error)) { // Some deployments have a stricter enum for sessions.device_type. // Retry without deviceType so auth/login is not blocked by schema drift. session.deviceType = undefined; await this.sessionRepository.save(session); } else { throw error; } } return { accessToken, refreshToken, user: this.transformUserToDto(user), }; } private transformUserToDto(user: User): UserDto { const userDto = plainToClass(UserDto, user, { excludeExtraneousValues: false, }); userDto.roles = user.roles.map((role) => role.roleName); userDto.fullName = user.fullName; return userDto; } private detectDeviceType(userAgent?: string): string { if (!userAgent) return 'web'; // Keep values aligned with DB enum used in production (ios/android/web). if (/(iphone|ipad|ipod|ios)/i.test(userAgent)) return 'ios'; if (/(android)/i.test(userAgent)) return 'android'; return 'web'; } private isDeviceTypeTruncationError(error: unknown): boolean { const err = error as { sqlMessage?: string; message?: string; code?: string; driverError?: { sqlMessage?: string; message?: string; code?: string }; }; const combinedMessage = [ err?.sqlMessage, err?.message, err?.driverError?.sqlMessage, err?.driverError?.message, ] .filter(Boolean) .join(' ') .toLowerCase(); const code = (err?.code || err?.driverError?.code || '').toLowerCase(); return ( combinedMessage.includes('device_type') && combinedMessage.includes('data truncated') ) || code === 'warn_data_truncated'; } }