streamflix-api / src /modules /user /auth /auth.service.ts
Akshar2325
feat(user): add profile management and enhance session tracking
405ccd5
Raw
History Blame
18.1 kB
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User, SESSION_STATUS, OTP_TYPE, OTP_PURPOSE } from '@prisma/client';
import { UserCoreService } from 'src/core/user-core/user-core.service';
import { UserCredentialCoreService } from 'src/core/user-credential-core/user-credential-core.service';
import { UserSessionCoreService } from 'src/core/user-session-core/user-session-core.service';
import { UserOtpCoreService } from 'src/core/user-otp-core/user-otp-core.service';
import { UserInterestCoreService } from 'src/core/user-interest-core/user-interest-core.service';
import { CommonService } from 'src/shared/modules/common/common.service';
import { EmailService } from 'src/shared/modules/email/email.service';
import { SmsService } from 'src/shared/modules/sms/sms.service';
import { UserRegisterDto } from './dto/register.dto';
import { UserLoginDto } from './dto/login.dto';
import { UserRefreshTokenDto } from './dto/refresh-token.dto';
import { VerifyEmailOtpDto } from './dto/verify-email-otp.dto';
import { ResendEmailOtpDto } from './dto/resend-email-otp.dto';
import { RegisterPhoneDto } from './dto/register-phone.dto';
import { VerifyPhoneOtpDto } from './dto/verify-phone-otp.dto';
import { CompleteProfileDto } from './dto/complete-profile.dto';
import { UserSessionType } from 'src/shared/types/user-session.type';
import {
accessTokenSignSettings,
refreshTokenSignSettings,
refreshTokenVerifySettings,
TOKEN_TYPE,
TOKEN_USER_TYPE,
AuthMessages,
} from 'src/shared/keys/auth.keys';
import { getUniqueId } from 'src/shared/modules/common/common.helper';
import { UNIQUE_ID_ENUM, USER_STATUS_ENUM } from 'src/keys';
@Injectable()
export class UserAuthService {
constructor(
private readonly jwtService: JwtService,
private readonly userCoreService: UserCoreService,
private readonly userCredentialCoreService: UserCredentialCoreService,
private readonly userSessionCoreService: UserSessionCoreService,
private readonly userOtpCoreService: UserOtpCoreService,
private readonly userInterestCoreService: UserInterestCoreService,
private readonly commonService: CommonService,
private readonly emailService: EmailService,
private readonly smsService: SmsService,
) {}
// PHASE 1: Initial Registration
async register(registerDto: UserRegisterDto): Promise<{ message: string }> {
const { email, password, firstName, lastName } = registerDto;
// Check if user already exists
const existingUser = await this.userCoreService
.findFirst({
where: { email, isDeleted: false },
})
.catch(() => null);
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
// Create combined name fields
const firstNameLastName = `${firstName} ${lastName}`;
const lastNameFirstName = `${lastName} ${firstName}`;
// Create user
const user = await this.userCoreService.create({
data: {
uniqueId: getUniqueId(UNIQUE_ID_ENUM.User),
email,
firstName,
lastName,
firstNameLastName,
lastNameFirstName,
status: USER_STATUS_ENUM.PENDING_EMAIL_VERIFICATION,
emailVerified: false,
phoneVerified: false,
},
});
// Hash and store password
const hashedPassword = await this.commonService.hashPassword(password);
await this.userCredentialCoreService.create({
data: {
userId: user.id,
password: hashedPassword,
},
});
// Generate and send OTP
await this.generateAndSendOtp(
user.id,
email,
OTP_TYPE.EMAIL,
OTP_PURPOSE.EMAIL_VERIFICATION,
firstName,
);
return {
message:
'Registration successful. Please check your email for OTP verification.',
};
}
// PHASE 2: Email Verification
async verifyEmailOtp(
verifyDto: VerifyEmailOtpDto,
): Promise<{ message: string }> {
const { email, otp } = verifyDto;
const user = await this.userCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!user) {
throw new BadRequestException('User not found');
}
if (user.emailVerified) {
throw new BadRequestException('Email already verified');
}
await this.verifyOtp(
user.id,
otp,
OTP_TYPE.EMAIL,
OTP_PURPOSE.EMAIL_VERIFICATION,
);
// Update user status
await this.userCoreService.update({
where: { id: user.id },
data: {
emailVerified: true,
status: USER_STATUS_ENUM.PENDING_PHONE_VERIFICATION,
},
});
return {
message:
'Email verified successfully. Please register your phone number.',
};
}
async resendEmailOtp(
resendDto: ResendEmailOtpDto,
): Promise<{ message: string }> {
const { email } = resendDto;
const user = await this.userCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!user) {
throw new BadRequestException('User not found');
}
if (user.emailVerified) {
throw new BadRequestException('Email already verified');
}
await this.generateAndSendOtp(
user.id,
email,
OTP_TYPE.EMAIL,
OTP_PURPOSE.EMAIL_VERIFICATION,
user.firstName || undefined,
);
return { message: 'OTP resent successfully' };
}
// PHASE 3: Phone Registration
async registerPhone(
registerPhoneDto: RegisterPhoneDto,
): Promise<{ message: string }> {
const { email, code, phone } = registerPhoneDto;
const user = await this.userCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!user) {
throw new BadRequestException('User not found');
}
if (!user.emailVerified) {
throw new BadRequestException('Please verify your email first');
}
if (user.phoneVerified) {
throw new BadRequestException('Phone already registered');
}
// Check if phone already exists
const existingPhone = await this.userCoreService
.findFirst({
where: { phone, isDeleted: false },
})
.catch(() => null);
if (existingPhone) {
throw new ConflictException('Phone number already registered');
}
// Update user with phone
await this.userCoreService.update({
where: { id: user.id },
data: { phone, code },
});
// Generate and send OTP
await this.generateAndSendOtp(
user.id,
`${phone}`,
OTP_TYPE.SMS,
OTP_PURPOSE.PHONE_VERIFICATION,
);
return {
message: 'Phone registered. Please check your SMS for OTP verification.',
};
}
// PHASE 4: Phone Verification
async verifyPhoneOtp(
verifyDto: VerifyPhoneOtpDto,
): Promise<{ message: string }> {
const { email, otp } = verifyDto;
const user = await this.userCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!user) {
throw new BadRequestException('User not found');
}
if (!user.emailVerified) {
throw new BadRequestException('Please verify your email first');
}
if (user.phoneVerified) {
throw new BadRequestException('Phone already verified');
}
await this.verifyOtp(
user.id,
otp,
OTP_TYPE.SMS,
OTP_PURPOSE.PHONE_VERIFICATION,
);
// Update user status
await this.userCoreService.update({
where: { id: user.id },
data: {
phoneVerified: true,
status: USER_STATUS_ENUM.PENDING_PROFILE_COMPLETION,
},
});
return {
message:
'Phone verified successfully. Please complete your profile to activate your account.',
};
}
// PHASE 5: Complete Profile
async completeProfile(
completeDto: CompleteProfileDto,
request: any,
): Promise<{
user: User;
accessToken: string;
refreshToken: string;
}> {
const { email, genreIds, dateOfBirth, country, city, bio } = completeDto;
const user = await this.userCoreService.findFirst({
where: { email, isDeleted: false },
});
if (!user) {
throw new BadRequestException('User not found');
}
if (!user.emailVerified || !user.phoneVerified) {
throw new BadRequestException('Please verify your email and phone first');
}
if (user.status === USER_STATUS_ENUM.ENABLED) {
throw new BadRequestException('Profile already completed');
}
// Update user profile
const updatedUser = await this.userCoreService.update({
where: { id: user.id },
data: {
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
country,
city,
bio,
status: USER_STATUS_ENUM.ENABLED,
},
});
// Save user interests (if provided)
if (genreIds && genreIds.length > 0) {
for (const genreId of genreIds) {
await this.userInterestCoreService.create({
data: {
userId: user.id,
genreId,
},
});
}
}
// Generate tokens
const { accessToken, refreshToken } = await this.getNewToken(updatedUser);
// Get client info
const clientInfo = this.commonService.getClientInfo(request);
// Create session
await this.userSessionCoreService.create({
data: {
userId: updatedUser.id,
accessToken,
refreshToken,
ipAddress: clientInfo.ipAddress,
userAgent: clientInfo.userAgent,
geoIpCountry: clientInfo.geoLocation?.country || '',
city: clientInfo.geoLocation?.city || '',
state: clientInfo.geoLocation?.region || '',
latitude: clientInfo.geoLocation?.ll?.[0] || null,
longitude: clientInfo.geoLocation?.ll?.[1] || null,
status: SESSION_STATUS.CURRENT,
loginAt: new Date(),
},
});
// Send welcome email (don't block profile completion if email fails)
try {
await this.emailService.sendWelcomeEmail(
email,
user.firstName || 'User',
user.lastName || '',
);
} catch (emailError) {
// Log error but don't fail the request
console.error('Failed to send welcome email:', emailError);
}
return {
user: updatedUser,
accessToken,
refreshToken,
};
}
// Login (Only for verified users)
async login(
loginDto: UserLoginDto,
request: any,
): Promise<{
user: User;
accessToken: string;
refreshToken: string;
}> {
const { email, password } = loginDto;
// Find user
const user = await this.userCoreService
.findFirst({
where: { email, isDeleted: false },
})
.catch(() => null);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Check if user is verified
if (user.status !== USER_STATUS_ENUM.ENABLED) {
throw new UnauthorizedException(
'Please complete your registration process',
);
}
// Get user credential
const credential = await this.userCredentialCoreService
.findFirst({
where: { userId: user.id, isDeleted: false },
})
.catch(() => null);
if (!credential) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
const isPasswordValid = await this.commonService.comparePassword(
password,
credential.password,
);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
// Generate tokens
const { accessToken, refreshToken } = await this.getNewToken(user);
// Get client info
const clientInfo = this.commonService.getClientInfo(request);
// Create session
await this.userSessionCoreService.create({
data: {
userId: user.id,
accessToken,
refreshToken,
ipAddress: clientInfo.ipAddress,
userAgent: clientInfo.userAgent,
geoIpCountry: clientInfo.geoLocation?.country || '',
city: clientInfo.geoLocation?.city || '',
state: clientInfo.geoLocation?.region || '',
latitude: clientInfo.geoLocation?.ll?.[0] || null,
longitude: clientInfo.geoLocation?.ll?.[1] || null,
status: SESSION_STATUS.CURRENT,
loginAt: new Date(),
},
});
// Update last login
await this.userCoreService.update({
where: { id: user.id },
data: { lastLogin: new Date() },
});
return {
user,
accessToken,
refreshToken,
};
}
async logout(sessionData: UserSessionType): Promise<{ message: string }> {
const { session } = sessionData;
// Update session to mark as logged out
await this.userSessionCoreService.update({
where: { id: session.id },
data: {
status: SESSION_STATUS.EXPIRED,
logoutAt: new Date(),
expiredAt: new Date(),
},
});
return { message: AuthMessages.LOGOUT_SUCCESS };
}
async refreshToken(refreshTokenDto: UserRefreshTokenDto): Promise<{
accessToken: string;
refreshToken: string;
}> {
const { refreshToken } = refreshTokenDto;
let validateRefreshToken: any = null;
try {
validateRefreshToken = await this.jwtService.verifyAsync(
refreshToken,
refreshTokenVerifySettings,
);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
// Verify user type
if (validateRefreshToken.userType !== TOKEN_USER_TYPE.USER) {
throw new UnauthorizedException('Invalid token type');
}
const user = await this.userCoreService.findUnique({
where: { id: validateRefreshToken.id, isDeleted: false },
});
if (!user) {
throw new UnauthorizedException('User not found');
}
// Generate new tokens
const newTokens = await this.getNewToken(user);
// Update session with new tokens
await this.userSessionCoreService.updateMany({
where: {
userId: user.id,
refreshToken,
status: SESSION_STATUS.CURRENT,
},
data: {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
},
});
return newTokens;
}
private async getNewToken(user: User): Promise<{
accessToken: string;
refreshToken: string;
}> {
const payload = {
id: user.id,
email: user.email,
userType: TOKEN_USER_TYPE.USER,
};
const accessToken = await this.jwtService.signAsync(
{ ...payload, type: TOKEN_TYPE.ACCESS },
accessTokenSignSettings,
);
const refreshToken = await this.jwtService.signAsync(
{ ...payload, type: TOKEN_TYPE.REFRESH },
refreshTokenSignSettings,
);
return { accessToken, refreshToken };
}
async performJWTStrategy({
request,
user,
}: {
request: Request & { headers: { authorization: string } };
user: User;
}): Promise<UserSessionType> {
const accessToken = request.headers.authorization?.replace('Bearer ', '');
if (!accessToken) {
throw new UnauthorizedException('Invalid token');
}
// Get full user object
const fullUser = await this.userCoreService.findUnique({
where: { id: user.id, isDeleted: false },
});
if (!fullUser) {
throw new UnauthorizedException('User not found');
}
// Find active session
const session = await this.userSessionCoreService.findFirst({
where: {
userId: user.id,
accessToken,
status: SESSION_STATUS.CURRENT,
isDeleted: false,
},
});
if (!session) {
throw new UnauthorizedException('Session not found');
}
return {
user: fullUser,
session,
};
}
// Helper: Generate and send OTP
private async generateAndSendOtp(
userId: string,
destination: string,
type: OTP_TYPE,
purpose: OTP_PURPOSE,
firstName?: string,
): Promise<void> {
// Generate 6-digit OTP
const otp = Math.floor(100000 + Math.random() * 900000).toString();
// OTP expires in 10 minutes
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
// Invalidate previous OTPs
await this.userOtpCoreService.updateMany({
where: {
userId,
type,
purpose,
verified: false,
isDeleted: false,
},
data: { isDeleted: true },
});
// Create new OTP
await this.userOtpCoreService.create({
data: {
userId,
otp,
type,
purpose,
expiresAt,
},
});
// Send OTP
if (type === OTP_TYPE.EMAIL) {
await this.emailService.sendOtpEmail(destination, otp, firstName);
} else if (type === OTP_TYPE.SMS) {
await this.smsService.sendOtpSms(destination, otp);
}
}
// Helper: Verify OTP
private async verifyOtp(
userId: string,
otp: string,
type: OTP_TYPE,
purpose: OTP_PURPOSE,
): Promise<void> {
const otpRecord = await this.userOtpCoreService
.findFirst({
where: {
userId,
type,
purpose,
verified: false,
isDeleted: false,
},
orderBy: { createdAt: 'desc' },
})
.catch(() => null);
if (!otpRecord) {
throw new BadRequestException('OTP not found or already used');
}
// Check if OTP is expired
if (new Date() > otpRecord.expiresAt) {
throw new BadRequestException('OTP has expired');
}
// Check attempts
if (otpRecord.attempts >= 5) {
throw new BadRequestException(
'Too many attempts. Please request a new OTP',
);
}
// Verify OTP
if (otpRecord.otp !== otp) {
// Increment attempts
await this.userOtpCoreService.update({
where: { id: otpRecord.id },
data: { attempts: otpRecord.attempts + 1 },
});
throw new BadRequestException('Invalid OTP');
}
// Mark OTP as verified
await this.userOtpCoreService.update({
where: { id: otpRecord.id },
data: {
verified: true,
verifiedAt: new Date(),
},
});
}
}