import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { EventEmitter2 } from '@nestjs/event-emitter'; import * as argon2 from 'argon2'; import slugify from 'slugify'; import { PrismaService } from '../prisma/prisma.service'; import { RegisterDto, LoginDto, SocialAuthDto, ForgotPasswordDto, ResetPasswordDto, RefreshTokenDto, VerifyEmailDto, } from './dto'; import { UserRole, AuthProvider, UserStatus } from '@prisma/client'; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly eventEmitter: EventEmitter2, ) {} // ========================================== // REGISTER // ========================================== async register(dto: RegisterDto) { // Validate agentTypeId is required for AGENT role if (dto.role === 'AGENT') { if (!dto.agentTypeId) { throw new BadRequestException('Agent type is required for agent registration'); } // Verify agent type exists const agentType = await this.prisma.agentType.findUnique({ where: { id: dto.agentTypeId }, }); if (!agentType) { throw new BadRequestException('Invalid agent type selected'); } } // Check if user exists const existingUser = await this.prisma.user.findUnique({ where: { email: dto.email.toLowerCase() }, }); if (existingUser) { throw new ConflictException('Email already registered'); } // Hash password const hashedPassword = await argon2.hash(dto.password); // Determine role const role = dto.role === 'AGENT' ? UserRole.AGENT : UserRole.USER; // Create user const user = await this.prisma.user.create({ data: { email: dto.email.toLowerCase(), password: hashedPassword, role, status: UserStatus.ACTIVE, authProvider: AuthProvider.LOCAL, }, }); // Create profile based on role if (role === UserRole.USER) { await this.prisma.userProfile.create({ data: { userId: user.id, firstName: dto.firstName, lastName: dto.lastName, }, }); } else if (role === UserRole.AGENT) { const slug = await this.generateUniqueSlug(dto.firstName, dto.lastName); await this.prisma.agentProfile.create({ data: { userId: user.id, slug, firstName: dto.firstName, lastName: dto.lastName, ...(dto.agentTypeId && { agentTypeId: dto.agentTypeId }), }, }); } // Emit event for email verification (handled by email service) this.eventEmitter.emit('user.registered', { userId: user.id, email: user.email, role: user.role, firstName: dto.firstName, lastName: dto.lastName, }); // Return message to verify email - no tokens until email is verified return { message: 'Registration successful! Please check your email to verify your account before logging in.', user: { id: user.id, email: user.email, role: user.role, firstName: dto.firstName, lastName: dto.lastName, }, }; } // ========================================== // LOGIN // ========================================== async login(dto: LoginDto, userAgent?: string, ipAddress?: string) { const user = await this.prisma.user.findUnique({ where: { email: dto.email.toLowerCase() }, include: { userProfile: true, agentProfile: true, }, }); if (!user) { throw new UnauthorizedException('Invalid email or password'); } if (!user.password) { throw new UnauthorizedException( 'This account uses social login. Please sign in with Google or Facebook.', ); } if (user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('Account is not active'); } // Check if email is verified if (!user.emailVerified) { throw new UnauthorizedException( 'Please verify your email before logging in. Check your inbox for the verification link.', ); } // Verify password const isPasswordValid = await argon2.verify(user.password, dto.password); if (!isPasswordValid) { throw new UnauthorizedException('Invalid email or password'); } // Generate tokens const tokens = await this.generateTokens(user.id, user.email, user.role); // Create session await this.createSession( user.id, tokens.accessToken, tokens.refreshToken, userAgent, ipAddress, ); // Update last login await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); // Get profile based on role const profile = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; return { user: { id: user.id, email: user.email, role: user.role, firstName: profile?.firstName, lastName: profile?.lastName, avatar: profile?.avatar, }, ...tokens, }; } // ========================================== // SOCIAL AUTH // ========================================== async socialAuth(dto: SocialAuthDto) { const providerIdField = dto.provider === 'google' ? 'googleId' : dto.provider === 'facebook' ? 'facebookId' : 'twitterId'; // Check if user exists with this provider ID let user = await this.prisma.user.findFirst({ where: { [providerIdField]: dto.providerId }, include: { userProfile: true, agentProfile: true, }, }); if (!user) { // Check if user exists with this email user = await this.prisma.user.findUnique({ where: { email: dto.email.toLowerCase() }, include: { userProfile: true, agentProfile: true, }, }); if (user) { // Link provider to existing account await this.prisma.user.update({ where: { id: user.id }, data: { [providerIdField]: dto.providerId }, }); } else { // Create new user const role = dto.role === 'AGENT' ? UserRole.AGENT : UserRole.USER; const authProvider = dto.provider === 'google' ? AuthProvider.GOOGLE : dto.provider === 'facebook' ? AuthProvider.FACEBOOK : AuthProvider.TWITTER; // Parse name const nameParts = dto.name?.split(' ') || []; const firstName = nameParts[0] || ''; const lastName = nameParts.slice(1).join(' ') || ''; user = await this.prisma.user.create({ data: { email: dto.email.toLowerCase(), role, status: UserStatus.ACTIVE, emailVerified: true, emailVerifiedAt: new Date(), authProvider, [providerIdField]: dto.providerId, }, include: { userProfile: true, agentProfile: true, }, }); // Create profile based on role if (role === UserRole.USER) { await this.prisma.userProfile.create({ data: { userId: user.id, firstName, lastName, avatar: dto.avatar, }, }); } else if (role === UserRole.AGENT) { const slug = await this.generateUniqueSlug(firstName, lastName); await this.prisma.agentProfile.create({ data: { userId: user.id, slug, firstName, lastName, avatar: dto.avatar, }, }); } // Refetch user with profile user = await this.prisma.user.findUnique({ where: { id: user.id }, include: { userProfile: true, agentProfile: true, }, }); } } if (!user) { throw new BadRequestException('Failed to create or find user'); } if (user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('Account is not active'); } // Generate tokens const tokens = await this.generateTokens(user.id, user.email, user.role); // Create session await this.createSession(user.id, tokens.accessToken, tokens.refreshToken); // Update last login await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); const profile = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; return { user: { id: user.id, email: user.email, role: user.role, firstName: profile?.firstName, lastName: profile?.lastName, avatar: profile?.avatar, }, ...tokens, }; } // ========================================== // FORGOT PASSWORD // ========================================== async forgotPassword(dto: ForgotPasswordDto) { const user = await this.prisma.user.findUnique({ where: { email: dto.email.toLowerCase() }, }); if (!user) { throw new NotFoundException('No account found with this email address'); } if (!user.password) { throw new BadRequestException( 'This account uses social login (Google/Facebook). Please sign in with your social account.', ); } // Generate reset token (short-lived JWT) const resetToken = this.jwtService.sign( { sub: user.id, email: user.email, type: 'password-reset' }, { secret: this.configService.get('jwt.secret'), expiresIn: '1h', }, ); // Emit event for email service this.eventEmitter.emit('password.reset-requested', { userId: user.id, email: user.email, resetToken, resetUrl: `${this.configService.get('app.frontendUrl')}/reset-password?token=${resetToken}`, }); return { message: 'Password reset link has been sent to your email', }; } // ========================================== // RESET PASSWORD // ========================================== async resetPassword(dto: ResetPasswordDto) { try { const payload = this.jwtService.verify(dto.token, { secret: this.configService.get('jwt.secret'), }); if (payload.type !== 'password-reset') { throw new BadRequestException('Invalid reset token'); } const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, }); if (!user) { throw new NotFoundException('User not found'); } // Hash new password const hashedPassword = await argon2.hash(dto.password); // Update password await this.prisma.user.update({ where: { id: user.id }, data: { password: hashedPassword }, }); // Invalidate all existing sessions await this.prisma.session.deleteMany({ where: { userId: user.id }, }); // Emit event this.eventEmitter.emit('password.reset-completed', { userId: user.id, email: user.email, }); return { message: 'Password reset successful. Please login with your new password.', }; } catch (error) { if (error.name === 'TokenExpiredError') { throw new BadRequestException('Reset token has expired'); } if (error.name === 'JsonWebTokenError') { throw new BadRequestException('Invalid reset token'); } throw error; } } // ========================================== // VERIFY EMAIL // ========================================== async verifyEmail(dto: VerifyEmailDto) { try { const payload = this.jwtService.verify(dto.token, { secret: this.configService.get('jwt.secret'), }); if (payload.type !== 'email-verification') { throw new BadRequestException('Invalid verification token'); } const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, include: { userProfile: true, agentProfile: true, }, }); if (!user) { throw new NotFoundException('User not found'); } if (user.emailVerified) { return { message: 'Email is already verified', }; } // Update user as verified await this.prisma.user.update({ where: { id: user.id }, data: { emailVerified: true, emailVerifiedAt: new Date(), }, }); // Get profile for name const profile = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; // Emit event for welcome email this.eventEmitter.emit('user.email-verified', { email: user.email, name: profile?.firstName, }); return { message: 'Email verified successfully', }; } catch (error) { if (error.name === 'TokenExpiredError') { throw new BadRequestException('Verification token has expired'); } if (error.name === 'JsonWebTokenError') { throw new BadRequestException('Invalid verification token'); } throw error; } } // ========================================== // RESEND VERIFICATION EMAIL // ========================================== async resendVerificationEmail(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { userProfile: true, agentProfile: true, }, }); if (!user) { throw new NotFoundException('User not found'); } if (user.emailVerified) { return { message: 'Email is already verified', }; } // Get profile for name const profile = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; // Emit event for email service to send verification this.eventEmitter.emit('user.registered', { userId: user.id, email: user.email, role: user.role, firstName: profile?.firstName, lastName: profile?.lastName, }); return { message: 'Verification email sent', }; } // ========================================== // REFRESH TOKEN // ========================================== async refreshToken(dto: RefreshTokenDto) { try { const payload = this.jwtService.verify(dto.refreshToken, { secret: this.configService.get('jwt.secret'), }); // Check if session exists const session = await this.prisma.session.findFirst({ where: { userId: payload.sub, refreshToken: dto.refreshToken, }, }); if (!session) { throw new UnauthorizedException('Invalid refresh token'); } // Get user const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, }); if (!user || user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('User not found or inactive'); } // Generate new tokens const tokens = await this.generateTokens(user.id, user.email, user.role); // Update session await this.prisma.session.update({ where: { id: session.id }, data: { token: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: new Date( Date.now() + this.parseExpiration( this.configService.get('jwt.refreshExpiration') || '7d', ), ), }, }); return tokens; } catch (error) { if ( error.name === 'TokenExpiredError' || error.name === 'JsonWebTokenError' ) { throw new UnauthorizedException('Invalid or expired refresh token'); } throw error; } } // ========================================== // LOGOUT // ========================================== async logout(userId: string, accessToken?: string) { if (accessToken) { // Delete specific session await this.prisma.session.deleteMany({ where: { userId, token: accessToken, }, }); } else { // Delete all sessions for user await this.prisma.session.deleteMany({ where: { userId }, }); } return { message: 'Logged out successfully' }; } // ========================================== // GET CURRENT USER // ========================================== async getCurrentUser(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { userProfile: true, agentProfile: true, }, }); if (!user) { throw new NotFoundException('User not found'); } const profile = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; return { id: user.id, email: user.email, role: user.role, status: user.status, emailVerified: user.emailVerified, authProvider: user.authProvider, firstName: profile?.firstName, lastName: profile?.lastName, avatar: profile?.avatar, phone: profile?.phone, city: profile?.city, state: profile?.state, country: profile?.country, ...(user.role === UserRole.AGENT && user.agentProfile ? { slug: user.agentProfile.slug, bio: user.agentProfile.bio, headline: user.agentProfile.headline, yearsOfExperience: user.agentProfile.yearsOfExperience, licenseNumber: user.agentProfile.licenseNumber, companyName: user.agentProfile.companyName, isProfileComplete: user.agentProfile.isProfileComplete, profileCompleteness: user.agentProfile.profileCompleteness, } : {}), createdAt: user.createdAt, lastLoginAt: user.lastLoginAt, }; } // ========================================== // HELPER METHODS // ========================================== private async generateTokens(userId: string, email: string, role: UserRole) { const payload = { sub: userId, email, role }; const secret = this.configService.get('jwt.secret'); const accessExpiresIn = this.parseExpirationToSeconds( this.configService.get('jwt.accessExpiration') || '15m', ); const refreshExpiresIn = this.parseExpirationToSeconds( this.configService.get('jwt.refreshExpiration') || '7d', ); const accessToken = this.jwtService.sign(payload, { secret, expiresIn: accessExpiresIn, }); const refreshToken = this.jwtService.sign(payload, { secret, expiresIn: refreshExpiresIn, }); return { accessToken, refreshToken }; } private parseExpirationToSeconds(expiration: string): number { const match = expiration.match(/^(\d+)([smhd])$/); if (!match) return 900; // Default 15 minutes const value = parseInt(match[1], 10); const unit = match[2]; switch (unit) { case 's': return value; case 'm': return value * 60; case 'h': return value * 60 * 60; case 'd': return value * 24 * 60 * 60; default: return 900; } } private async createSession( userId: string, accessToken: string, refreshToken: string, userAgent?: string, ipAddress?: string, ) { const expiresAt = new Date( Date.now() + this.parseExpiration( this.configService.get('jwt.refreshExpiration') || '7d', ), ); // Use upsert to handle potential duplicate tokens await this.prisma.session.upsert({ where: { token: accessToken }, update: { refreshToken, userAgent, ipAddress, expiresAt, }, create: { userId, token: accessToken, refreshToken, userAgent, ipAddress, expiresAt, }, }); } private parseExpiration(expiration: string): number { const match = expiration.match(/^(\d+)([smhd])$/); if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days const value = parseInt(match[1], 10); const unit = match[2]; switch (unit) { case 's': return value * 1000; case 'm': return value * 60 * 1000; case 'h': return value * 60 * 60 * 1000; case 'd': return value * 24 * 60 * 60 * 1000; default: return 7 * 24 * 60 * 60 * 1000; } } private async generateUniqueSlug( firstName?: string, lastName?: string, ): Promise { const baseName = `${firstName || ''} ${lastName || ''}`.trim() || 'agent'; let slug = slugify(baseName, { lower: true, strict: true }); // Check if slug exists let counter = 0; let uniqueSlug = slug; while (true) { const existing = await this.prisma.agentProfile.findUnique({ where: { slug: uniqueSlug }, }); if (!existing) break; counter++; uniqueSlug = `${slug}-${counter}`; } return uniqueSlug; } }