mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-11-26 00:24:54 +00:00
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { FindOptionsWhere } from 'typeorm';
|
|
import { Transactional } from 'typeorm-transactional';
|
|
import { CountryIso } from '~/common/enums';
|
|
import { NotificationsService } from '~/common/modules/notification/services';
|
|
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
|
import { OtpService } from '~/common/modules/otp/services';
|
|
import { CustomerService } from '~/customer/services';
|
|
import { DocumentService, OciService } from '~/document/services';
|
|
import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request';
|
|
import { Roles } from '../../auth/enums';
|
|
import { UpdateNotificationsSettingsRequestDto, UpdateUserRequestDto } from '../dtos/request';
|
|
import { User } from '../entities';
|
|
import { UserRepository } from '../repositories';
|
|
import { DeviceService } from './device.service';
|
|
const SALT_ROUNDS = 10;
|
|
@Injectable()
|
|
export class UserService {
|
|
private readonly logger = new Logger(UserService.name);
|
|
constructor(
|
|
private readonly userRepository: UserRepository,
|
|
@Inject(forwardRef(() => NotificationsService)) private readonly notificationsService: NotificationsService,
|
|
private readonly deviceService: DeviceService,
|
|
private readonly configService: ConfigService,
|
|
private customerService: CustomerService,
|
|
private readonly documentService: DocumentService,
|
|
private readonly otpService: OtpService,
|
|
private readonly ociService: OciService,
|
|
) {}
|
|
|
|
async findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[], includeSignedUrl = false) {
|
|
this.logger.log(`finding user with where clause ${JSON.stringify(where)}`);
|
|
const user = await this.userRepository.findOne(where);
|
|
|
|
if (user?.profilePicture && includeSignedUrl) {
|
|
user.profilePicture.url = await this.ociService.generatePreSignedUrl(user.profilePicture);
|
|
}
|
|
return user;
|
|
}
|
|
|
|
setEmail(userId: string, email: string) {
|
|
this.logger.log(`Setting email ${email} for user ${userId}`);
|
|
return this.userRepository.update(userId, { email });
|
|
}
|
|
|
|
setPassword(userId: string, password: string, salt: string) {
|
|
this.logger.log(`Setting passcode for user ${userId}`);
|
|
return this.userRepository.update(userId, { password, salt });
|
|
}
|
|
|
|
setPhoneNumber(userId: string, phoneNumber: string, countryCode: string) {
|
|
this.logger.log(`Setting phone number ${phoneNumber} for user ${userId}`);
|
|
return this.userRepository.update(userId, { phoneNumber, countryCode });
|
|
}
|
|
|
|
@Transactional()
|
|
async verifyUser(userId: string, body: VerifyUserRequestDto) {
|
|
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
|
|
const hashedPassword = bcrypt.hashSync(body.password, salt);
|
|
this.logger.log(`Verifying user with id ${userId}`);
|
|
await Promise.all([
|
|
this.customerService.createGuardianCustomer(userId, {
|
|
firstName: body.firstName,
|
|
lastName: body.lastName,
|
|
dateOfBirth: body.dateOfBirth,
|
|
countryOfResidence: body.countryOfResidence,
|
|
}),
|
|
this.userRepository.update(userId, {
|
|
isPhoneVerified: true,
|
|
password: hashedPassword,
|
|
salt,
|
|
...(body.email && { email: body.email }),
|
|
}),
|
|
]);
|
|
}
|
|
|
|
async findUserOrThrow(where: FindOptionsWhere<User>, includeSignedUrl = false) {
|
|
this.logger.log(`Finding user with where clause ${JSON.stringify(where)}`);
|
|
const user = await this.findUser(where, includeSignedUrl);
|
|
|
|
if (!user) {
|
|
this.logger.error(`User with not found with where clause ${JSON.stringify(where)}`);
|
|
throw new BadRequestException('USER.NOT_FOUND');
|
|
}
|
|
|
|
this.logger.log(`User with where clause ${JSON.stringify(where)} found successfully`);
|
|
return user;
|
|
}
|
|
|
|
@Transactional()
|
|
async findOrCreateUser(body: CreateUnverifiedUserRequestDto) {
|
|
this.logger.log(`Finding or creating user with phone number ${body.countryCode + body.phoneNumber}`);
|
|
const user = await this.userRepository.findOne({
|
|
phoneNumber: body.phoneNumber,
|
|
countryCode: body.countryCode,
|
|
});
|
|
|
|
if (!user) {
|
|
this.logger.log(`User with phone number ${body.phoneNumber} not found, creating new user`);
|
|
return this.userRepository.createUnverifiedUser({
|
|
phoneNumber: body.phoneNumber,
|
|
countryCode: body.countryCode,
|
|
email: body.email,
|
|
firstName: body.firstName,
|
|
lastName: body.lastName,
|
|
roles: [Roles.GUARDIAN],
|
|
});
|
|
}
|
|
|
|
if (user && user.roles.includes(Roles.GUARDIAN) && user.isPhoneVerified) {
|
|
this.logger.error(`User with phone number ${body.phoneNumber} already exists`);
|
|
throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN');
|
|
}
|
|
|
|
if (user && user.roles.includes(Roles.JUNIOR)) {
|
|
this.logger.error(`User with phone number ${body.phoneNumber} is an already registered junior`);
|
|
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
|
|
//TODO add role Guardian to the existing user and send OTP
|
|
}
|
|
|
|
this.logger.log(`User with phone number ${body.phoneNumber} found successfully`);
|
|
return user;
|
|
}
|
|
|
|
async createUser(data: Partial<User>) {
|
|
this.logger.log(`Creating user with data ${JSON.stringify(data)}`);
|
|
const user = await this.userRepository.createUser(data);
|
|
|
|
this.logger.log(`User with data ${JSON.stringify(data)} created successfully`);
|
|
return user;
|
|
}
|
|
|
|
async updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto, deviceId?: string) {
|
|
this.logger.log(`Updating notification settings for user ${userId} with data ${JSON.stringify(data)}`);
|
|
if (data.isPushEnabled && !data.fcmToken) {
|
|
throw new BadRequestException('USER.FCM_TOKEN_REQUIRED');
|
|
}
|
|
|
|
if (data.isPushEnabled && !deviceId) {
|
|
throw new BadRequestException('DEVICE_ID_REQUIRED');
|
|
}
|
|
|
|
if (data.isPushEnabled && deviceId && data.fcmToken) {
|
|
await this.deviceService.updateDevice(deviceId, { fcmToken: data.fcmToken, userId });
|
|
}
|
|
|
|
await this.userRepository.update(userId, {
|
|
isPushEnabled: data.isPushEnabled,
|
|
isEmailEnabled: data.isEmailEnabled,
|
|
isSmsEnabled: data.isSmsEnabled,
|
|
});
|
|
}
|
|
|
|
async createGoogleUser(googleId: string, email: string, firstName: string, lastName: string) {
|
|
this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`);
|
|
const user = await this.userRepository.createUser({
|
|
googleId,
|
|
email,
|
|
roles: [Roles.GUARDIAN],
|
|
isEmailVerified: true,
|
|
});
|
|
|
|
await this.customerService.createGuardianCustomer(user.id, {
|
|
firstName,
|
|
lastName,
|
|
countryOfResidence: CountryIso.SAUDI_ARABIA,
|
|
});
|
|
|
|
return this.findUserOrThrow({ id: user.id });
|
|
}
|
|
|
|
async createAppleUser(appleId: string, email: string, firstName: string, lastName: string) {
|
|
this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`);
|
|
const user = await this.userRepository.createUser({
|
|
appleId,
|
|
email,
|
|
roles: [Roles.GUARDIAN],
|
|
isEmailVerified: true,
|
|
});
|
|
|
|
await this.customerService.createGuardianCustomer(user.id, {
|
|
firstName,
|
|
lastName,
|
|
countryOfResidence: CountryIso.SAUDI_ARABIA,
|
|
});
|
|
return this.findUserOrThrow({ id: user.id });
|
|
}
|
|
|
|
async updateUser(userId: string, data: UpdateUserRequestDto) {
|
|
await this.validateProfilePictureId(data.profilePictureId, userId);
|
|
|
|
if (data.email) {
|
|
const userWithEmail = await this.findUser({ email: data.email });
|
|
if (userWithEmail && userWithEmail.id !== userId) {
|
|
this.logger.error(`Email ${data.email} is already taken by another user`);
|
|
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
|
|
}
|
|
}
|
|
|
|
this.logger.log(`Updating user ${userId} with data ${JSON.stringify(data)}`);
|
|
|
|
const { gender, dateOfBirth, ...userData } = data;
|
|
|
|
const { affected } = await this.userRepository.update(userId, userData);
|
|
if (affected === 0) {
|
|
this.logger.error(`User with id ${userId} not found`);
|
|
throw new BadRequestException('USER.NOT_FOUND');
|
|
}
|
|
|
|
if (gender !== undefined || dateOfBirth !== undefined) {
|
|
const customerData: Partial<{ gender: typeof gender; dateOfBirth: Date }> = {};
|
|
if (gender !== undefined) {
|
|
customerData.gender = gender;
|
|
}
|
|
if (dateOfBirth !== undefined) {
|
|
customerData.dateOfBirth = dateOfBirth;
|
|
}
|
|
await this.customerService.updateCustomer(userId, customerData);
|
|
}
|
|
}
|
|
|
|
async updateUserEmail(userId: string, email: string) {
|
|
const userWithEmail = await this.findUser({ email });
|
|
|
|
if (userWithEmail) {
|
|
if (userWithEmail.id === userId) {
|
|
this.logger.log(`Generating OTP for current email ${email} for user ${userId}`);
|
|
await this.userRepository.update(userId, { isEmailVerified: false });
|
|
|
|
return this.otpService.generateAndSendOtp({
|
|
userId,
|
|
recipient: email,
|
|
otpType: OtpType.EMAIL,
|
|
scope: OtpScope.VERIFY_EMAIL,
|
|
});
|
|
}
|
|
|
|
this.logger.error(`Email ${email} is already taken by another user`);
|
|
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
|
|
}
|
|
|
|
this.logger.log(`Updating email for user ${userId} to ${email}`);
|
|
const { affected } = await this.userRepository.update(userId, { email, isEmailVerified: false });
|
|
|
|
if (affected === 0) {
|
|
this.logger.error(`User with id ${userId} not found`);
|
|
throw new BadRequestException('USER.NOT_FOUND');
|
|
}
|
|
|
|
return this.otpService.generateAndSendOtp({
|
|
userId,
|
|
recipient: email,
|
|
otpType: OtpType.EMAIL,
|
|
scope: OtpScope.VERIFY_EMAIL,
|
|
});
|
|
}
|
|
|
|
async verifyEmail(userId: string, otp: string) {
|
|
this.logger.log(`Verifying email for user ${userId} with otp ${otp}`);
|
|
const user = await this.findUserOrThrow({ id: userId });
|
|
|
|
if (user.isEmailVerified) {
|
|
this.logger.error(`User with id ${userId} already has verified email`);
|
|
throw new BadRequestException('USER.EMAIL_ALREADY_VERIFIED');
|
|
}
|
|
|
|
const isOtpValid = await this.otpService.verifyOtp({
|
|
userId,
|
|
value: otp,
|
|
scope: OtpScope.VERIFY_EMAIL,
|
|
otpType: OtpType.EMAIL,
|
|
});
|
|
|
|
if (!isOtpValid) {
|
|
this.logger.error(`Invalid OTP for user with email ${user.email}`);
|
|
throw new BadRequestException('OTP.INVALID_OTP');
|
|
}
|
|
|
|
await this.userRepository.update(userId, { isEmailVerified: true });
|
|
this.logger.log(`Email for user ${userId} verified successfully`);
|
|
}
|
|
|
|
private async validateProfilePictureId(profilePictureId: string, userId: string) {
|
|
if (!profilePictureId) {
|
|
return;
|
|
}
|
|
this.logger.log(`Validating profile picture id ${profilePictureId}`);
|
|
|
|
const document = await this.documentService.findDocumentById(profilePictureId);
|
|
|
|
if (!document) {
|
|
this.logger.error(`Document with id ${profilePictureId} not found`);
|
|
throw new BadRequestException('DOCUMENT.NOT_FOUND');
|
|
}
|
|
|
|
if (document.createdById !== userId) {
|
|
this.logger.error(`Document with id ${profilePictureId} does not belong to user ${userId}`);
|
|
throw new BadRequestException('DOCUMENT.NOT_BELONG_TO_USER');
|
|
}
|
|
|
|
this.logger.log(`Profile picture id ${profilePictureId} validated successfully`);
|
|
}
|
|
}
|