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 | FindOptionsWhere[], 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, 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) { 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`); } }