Files
backend/src/auth/auth.service.ts

795 lines
21 KiB
TypeScript

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<string>('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<string>('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<string>('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<string>('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<string>('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<string>('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<string>('jwt.secret');
const accessExpiresIn = this.parseExpirationToSeconds(
this.configService.get<string>('jwt.accessExpiration') || '15m',
);
const refreshExpiresIn = this.parseExpirationToSeconds(
this.configService.get<string>('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<string>('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<string> {
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;
}
}