diff --git a/package.json b/package.json index 7b5d0d8..73e9132 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "migration:generate": "npm run typeorm:cli-d migration:generate", "migration:create": "npm run typeorm:cli migration:create", "migration:up": "npm run typeorm:cli-d migration:run", - "migration:down": "npm run typeorm:cli-d migration:revert" + "migration:down": "npm run typeorm:cli-d migration:revert", + "seed": "TS_NODE_PROJECT=tsconfig.json ts-node -r tsconfig-paths/register src/scripts/seed.ts" }, "dependencies": { "@abdalhamid/hello": "^2.0.0", diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index ce8e56b..138e689 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -8,7 +8,7 @@ import { AuthService, Oauth2Service } from './services'; import { AccessTokenStrategy } from './strategies'; @Module({ - imports: [JwtModule.register({}), JuniorModule, UserModule, HttpModule], + imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule], providers: [AuthService, AccessTokenStrategy, Oauth2Service], controllers: [AuthController], exports: [], diff --git a/src/auth/dtos/response/login.response.dto.ts b/src/auth/dtos/response/login.response.dto.ts index f097a55..6e9ee15 100644 --- a/src/auth/dtos/response/login.response.dto.ts +++ b/src/auth/dtos/response/login.response.dto.ts @@ -18,11 +18,11 @@ export class LoginResponseDto { user!: UserResponseDto; @ApiProperty({ example: CustomerResponseDto }) - customer!: CustomerResponseDto; + customer!: CustomerResponseDto | null; constructor(IVerifyUserResponse: ILoginResponse, user: User) { this.user = new UserResponseDto(user); - this.customer = new CustomerResponseDto(user.customer); + this.customer = user.customer ? new CustomerResponseDto(user.customer) : null; this.accessToken = IVerifyUserResponse.accessToken; this.refreshToken = IVerifyUserResponse.refreshToken; this.expiresAt = IVerifyUserResponse.expiresAt; diff --git a/src/auth/dtos/response/user.response.dto.ts b/src/auth/dtos/response/user.response.dto.ts index 8d2c8ca..2d80e6f 100644 --- a/src/auth/dtos/response/user.response.dto.ts +++ b/src/auth/dtos/response/user.response.dto.ts @@ -21,6 +21,15 @@ export class UserResponseDto { @ApiProperty() isProfileCompleted!: boolean; + @ApiProperty() + isSmsEnabled!: boolean; + + @ApiProperty() + isEmailEnabled!: boolean; + + @ApiProperty() + isPushEnabled!: boolean; + @ApiProperty() roles!: Roles[]; @@ -31,6 +40,9 @@ export class UserResponseDto { this.countryCode = user.countryCode; this.isPasswordSet = user.isPasswordSet; this.isProfileCompleted = user.isProfileCompleted; + this.isSmsEnabled = user.isSmsEnabled; + this.isEmailEnabled = user.isEmailEnabled; + this.isPushEnabled = user.isPushEnabled; this.roles = user.roles; } } diff --git a/src/auth/enums/roles.enum.ts b/src/auth/enums/roles.enum.ts index 7df8306..d376a08 100644 --- a/src/auth/enums/roles.enum.ts +++ b/src/auth/enums/roles.enum.ts @@ -1,4 +1,6 @@ export enum Roles { JUNIOR = 'JUNIOR', GUARDIAN = 'GUARDIAN', + CHECKER = 'CHECKER', + SUPER_ADMIN = 'SUPER_ADMIN', } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 65a0b3f..4d35a4e 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -7,8 +7,8 @@ import { ArrayContains } from 'typeorm'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; -import { JuniorTokenService } from '~/junior/services'; -import { DeviceService, UserService } from '~/user/services'; +import { UserType } from '~/user/enums'; +import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; import { PASSCODE_REGEX } from '../constants'; import { @@ -39,7 +39,7 @@ export class AuthService { private readonly configService: ConfigService, private readonly userService: UserService, private readonly deviceService: DeviceService, - private readonly juniorTokenService: JuniorTokenService, + private readonly userTokenService: UserTokenService, private readonly cacheService: CacheService, private readonly oauth2Service: Oauth2Service, ) {} @@ -91,13 +91,15 @@ export class AuthService { return [tokens, user]; } - const updatedUser = await this.userService.verifyUserAndCreateCustomer(user.id); + await this.userService.verifyPhoneNumber(user.id); - const tokens = await this.generateAuthToken(updatedUser); + await user.reload(); + + const tokens = await this.generateAuthToken(user); this.logger.log( `User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} verified successfully`, ); - return [tokens, updatedUser]; + return [tokens, user]; } async setEmail(userId: string, { email }: SetEmailRequestDto) { @@ -293,11 +295,11 @@ export class AuthService { async setJuniorPasscode(body: setJuniorPasswordRequestDto) { this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`); - const juniorId = await this.juniorTokenService.validateToken(body.qrToken); + const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR); const salt = bcrypt.genSaltSync(SALT_ROUNDS); const hashedPasscode = bcrypt.hashSync(body.passcode, salt); - await this.userService.setPasscode(juniorId, hashedPasscode, salt); - await this.juniorTokenService.invalidateToken(body.qrToken); + await this.userService.setPasscode(juniorId!, hashedPasscode, salt); + await this.userTokenService.invalidateToken(body.qrToken); this.logger.log(`Passcode set successfully for junior with id ${juniorId}`); } diff --git a/src/common/modules/notification/enums/notification-scope.enum.ts b/src/common/modules/notification/enums/notification-scope.enum.ts index 787e715..1f6cd4b 100644 --- a/src/common/modules/notification/enums/notification-scope.enum.ts +++ b/src/common/modules/notification/enums/notification-scope.enum.ts @@ -3,4 +3,5 @@ export enum NotificationScope { TASK_COMPLETED = 'TASK_COMPLETED', GIFT_RECEIVED = 'GIFT_RECEIVED', OTP = 'OTP', + USER_INVITED = 'USER_INVITED', } diff --git a/src/common/modules/notification/notification.module.ts b/src/common/modules/notification/notification.module.ts index c8033c3..8f99511 100644 --- a/src/common/modules/notification/notification.module.ts +++ b/src/common/modules/notification/notification.module.ts @@ -1,5 +1,5 @@ import { MailerModule } from '@nestjs-modules/mailer'; -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TwilioModule } from 'nestjs-twilio'; @@ -21,7 +21,7 @@ import { FirebaseService, NotificationsService, TwilioService } from './services useFactory: buildMailerOptions, inject: [ConfigService], }), - UserModule, + forwardRef(() => UserModule), ], providers: [NotificationsService, FirebaseService, NotificationsRepository, TwilioService], exports: [NotificationsService], diff --git a/src/common/modules/notification/services/notifications.service.ts b/src/common/modules/notification/services/notifications.service.ts index cec2123..9e7c5c3 100644 --- a/src/common/modules/notification/services/notifications.service.ts +++ b/src/common/modules/notification/services/notifications.service.ts @@ -25,36 +25,6 @@ export class NotificationsService { private readonly mailerService: MailerService, ) {} - async sendPushNotification(userId: string, title: string, body: string) { - this.logger.log(`Sending push notification to user ${userId}`); - // Get the device tokens for the user - - const tokens = await this.deviceService.getTokens(userId); - - if (!tokens.length) { - this.logger.log(`No device tokens found for user ${userId} but notification created in the database`); - return; - } - // Send the notification - return this.firebaseService.sendNotification(tokens, title, body); - } - - async sendSMS(to: string, body: string) { - this.logger.log(`Sending SMS to ${to}`); - await this.twilioService.sendSMS(to, body); - } - - async sendEmail({ to, subject, data, template }: SendEmailRequestDto) { - this.logger.log(`Sending email to ${to}`); - await this.mailerService.sendMail({ - to, - subject, - template, - context: { ...data }, - }); - this.logger.log(`Email sent to ${to}`); - } - async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) { this.logger.log(`Getting notifications for user ${userId}`); const [[notifications, count], unreadCount] = await Promise.all([ @@ -77,6 +47,18 @@ export class NotificationsService { return this.notificationRepository.markAsRead(userId); } + async sendEmailAsync(data: SendEmailRequestDto) { + this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`); + const notification = await this.createNotification({ + recipient: data.to, + title: data.subject, + message: '', + scope: NotificationScope.USER_INVITED, + channel: NotificationChannel.EMAIL, + }); + return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification, data.data); + } + async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) { this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`); const notification = await this.createNotification({ @@ -92,10 +74,42 @@ export class NotificationsService { return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification); } + private async sendPushNotification(userId: string, title: string, body: string) { + this.logger.log(`Sending push notification to user ${userId}`); + // Get the device tokens for the user + + const tokens = await this.deviceService.getTokens(userId); + + if (!tokens.length) { + this.logger.log(`No device tokens found for user ${userId} but notification created in the database`); + return; + } + // Send the notification + return this.firebaseService.sendNotification(tokens, title, body); + } + + private async sendSMS(to: string, body: string) { + this.logger.log(`Sending SMS to ${to}`); + await this.twilioService.sendSMS(to, body); + } + + private async sendEmail({ to, subject, data, template }: SendEmailRequestDto) { + this.logger.log(`Sending email to ${to}`); + await this.mailerService.sendMail({ + to, + subject, + template, + context: { ...data }, + }); + this.logger.log(`Email sent to ${to}`); + } + private getTemplateFromNotification(notification: Notification) { switch (notification.scope) { case NotificationScope.OTP: return 'otp'; + case NotificationScope.USER_INVITED: + return 'user-invite'; default: return 'otp'; } @@ -115,8 +129,8 @@ export class NotificationsService { return this.sendEmail({ to: notification.recipient!, subject: notification.title, - data, template: this.getTemplateFromNotification(notification), + data, }); } } diff --git a/src/common/modules/notification/templates/user-invite.hbs b/src/common/modules/notification/templates/user-invite.hbs new file mode 100644 index 0000000..dedacb3 --- /dev/null +++ b/src/common/modules/notification/templates/user-invite.hbs @@ -0,0 +1,71 @@ + + + + + + You're Invited! + + + +
+
You're Invited to Join Us!
+

+ You've been invited to join our platform. Click the button below to accept the invitation and set up your account. +

+ Accept Invitation +

+ If the button above doesn't work, you can copy and paste this link into your browser:
+ {{inviteLink}} +

+ +
+ + diff --git a/src/customer/controllers/customer.controller.ts b/src/customer/controllers/customer.controller.ts index ce26a7f..6703210 100644 --- a/src/customer/controllers/customer.controller.ts +++ b/src/customer/controllers/customer.controller.ts @@ -1,13 +1,12 @@ -import { Body, Controller, Get, Headers, Patch, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { IJwtPayload } from '~/auth/interfaces'; -import { DEVICE_ID_HEADER } from '~/common/constants'; import { AuthenticatedUser } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; -import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response'; +import { CreateCustomerRequestDto, UpdateCustomerRequestDto } from '../dtos/request'; +import { CustomerResponseDto } from '../dtos/response'; import { CustomerService } from '../services'; @Controller('customers') @@ -26,7 +25,7 @@ export class CustomerController { return ResponseFactory.data(new CustomerResponseDto(customer)); } - @Patch('') + @Patch() @UseGuards(AccessTokenGuard) @ApiDataResponse(CustomerResponseDto) async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) { @@ -35,16 +34,12 @@ export class CustomerController { return ResponseFactory.data(new CustomerResponseDto(customer)); } - @Patch('settings/notifications') + @Post('') @UseGuards(AccessTokenGuard) - @ApiDataResponse(NotificationSettingsResponseDto) - async updateNotificationSettings( - @AuthenticatedUser() { sub }: IJwtPayload, - @Body() body: UpdateNotificationsSettingsRequestDto, - @Headers(DEVICE_ID_HEADER) deviceId: string, - ) { - const notificationSettings = await this.customerService.updateNotificationSettings(sub, body, deviceId); + @ApiDataResponse(CustomerResponseDto) + async createCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateCustomerRequestDto) { + const customer = await this.customerService.createGuardianCustomer(sub, body); - return ResponseFactory.data(new NotificationSettingsResponseDto(notificationSettings)); + return ResponseFactory.data(new CustomerResponseDto(customer)); } } diff --git a/src/customer/controllers/index.ts b/src/customer/controllers/index.ts index 26207a4..64166b0 100644 --- a/src/customer/controllers/index.ts +++ b/src/customer/controllers/index.ts @@ -1 +1,2 @@ export * from './customer.controller'; +export * from './internal.customer.controller'; diff --git a/src/customer/controllers/internal.customer.controller.ts b/src/customer/controllers/internal.customer.controller.ts new file mode 100644 index 0000000..1651250 --- /dev/null +++ b/src/customer/controllers/internal.customer.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CustomParseUUIDPipe } from '~/core/pipes'; +import { ResponseFactory } from '~/core/utils'; +import { CustomerFiltersRequestDto, RejectCustomerKycRequestDto } from '../dtos/request'; +import { CustomerResponseDto } from '../dtos/response'; +import { CustomerService } from '../services'; + +@ApiTags('Customers') +@Controller('internal/customers') +export class InternalCustomerController { + constructor(private readonly customerService: CustomerService) {} + @Get() + async findCustomers(@Query() filters: CustomerFiltersRequestDto) { + const [customers, count] = await this.customerService.findCustomers(filters); + + return ResponseFactory.dataPage( + customers.map((customer) => new CustomerResponseDto(customer)), + { + page: filters.page, + size: filters.size, + itemCount: count, + }, + ); + } + + @Get(':customerId') + async findCustomerById(@Param('customerId', CustomParseUUIDPipe) customerId: string) { + const customer = await this.customerService.findCustomerById(customerId); + + return ResponseFactory.data(new CustomerResponseDto(customer)); + } + + @Patch(':customerId/approve') + @HttpCode(HttpStatus.NO_CONTENT) + async approveKycForCustomer(@Param('customerId', CustomParseUUIDPipe) customerId: string) { + await this.customerService.approveKycForCustomer(customerId); + } + + @Patch(':customerId/reject') + @HttpCode(HttpStatus.NO_CONTENT) + async rejectKycForCustomer( + @Param('customerId', CustomParseUUIDPipe) customerId: string, + @Body() body: RejectCustomerKycRequestDto, + ) { + await this.customerService.rejectKycForCustomer(customerId, body); + } +} diff --git a/src/customer/customer.module.ts b/src/customer/customer.module.ts index 90eb700..a37bae9 100644 --- a/src/customer/customer.module.ts +++ b/src/customer/customer.module.ts @@ -1,15 +1,15 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { GuardianModule } from '~/guardian/guardian.module'; import { UserModule } from '~/user/user.module'; -import { CustomerController } from './controllers'; +import { CustomerController, InternalCustomerController } from './controllers'; import { Customer } from './entities'; -import { CustomerNotificationSettings } from './entities/customer-notification-settings.entity'; import { CustomerRepository } from './repositories/customer.repository'; import { CustomerService } from './services'; @Module({ - imports: [TypeOrmModule.forFeature([Customer, CustomerNotificationSettings]), forwardRef(() => UserModule)], - controllers: [CustomerController], + imports: [TypeOrmModule.forFeature([Customer]), forwardRef(() => UserModule), GuardianModule], + controllers: [CustomerController, InternalCustomerController], providers: [CustomerService, CustomerRepository], exports: [CustomerService], }) diff --git a/src/customer/dtos/request/create-customer.request.dto.ts b/src/customer/dtos/request/create-customer.request.dto.ts new file mode 100644 index 0000000..5e6cfd1 --- /dev/null +++ b/src/customer/dtos/request/create-customer.request.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { IsAbove18 } from '~/core/decorators/validations'; +export class CreateCustomerRequestDto { + @ApiProperty({ example: 'John' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) + @IsOptional() + firstName!: string; + + @ApiProperty({ example: 'Doe' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) + @IsOptional() + lastName!: string; + + @ApiProperty({ example: 'JO' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) }) + @IsOptional() + countryOfResidence!: string; + + @ApiProperty({ example: '2021-01-01' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) + @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) + @IsOptional() + dateOfBirth!: Date; + + @ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' }) + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdFrontId' }) }) + @IsOptional() + civilIdFrontId!: string; + + @ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' }) + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdBackId' }) }) + @IsOptional() + civilIdBackId!: string; +} diff --git a/src/customer/dtos/request/customer-filters.request.dto.ts b/src/customer/dtos/request/customer-filters.request.dto.ts new file mode 100644 index 0000000..c2135ab --- /dev/null +++ b/src/customer/dtos/request/customer-filters.request.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { KycStatus } from '~/customer/enums'; + +export class CustomerFiltersRequestDto extends PageOptionsRequestDto { + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.name' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.name' }) }) + @IsOptional() + @ApiPropertyOptional({ description: 'search by name' }) + name?: string; + + @IsEnum(KycStatus, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.kycStatus' }) }) + @IsOptional() + @ApiPropertyOptional({ + enum: KycStatus, + enumName: 'KycStatus', + example: KycStatus.PENDING, + description: 'kyc status of the customer', + }) + kycStatus?: string; +} diff --git a/src/customer/dtos/request/index.ts b/src/customer/dtos/request/index.ts index a06f59b..1678547 100644 --- a/src/customer/dtos/request/index.ts +++ b/src/customer/dtos/request/index.ts @@ -1,2 +1,4 @@ +export * from './create-customer.request.dto'; +export * from './customer-filters.request.dto'; +export * from './reject-customer-kyc.request.dto'; export * from './update-customer.request.dto'; -export * from './update-notifications-settings.request.dto'; diff --git a/src/customer/dtos/request/reject-customer-kyc.request.dto.ts b/src/customer/dtos/request/reject-customer-kyc.request.dto.ts new file mode 100644 index 0000000..bec17b5 --- /dev/null +++ b/src/customer/dtos/request/reject-customer-kyc.request.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; + +export class RejectCustomerKycRequestDto { + @ApiPropertyOptional({ description: 'reason for rejecting the customer kyc' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.rejectionReason' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.rejectionReason' }) }) + @IsOptional({ message: i18n('validation.IsOptional', { path: 'general', property: 'customer.rejectionReason' }) }) + reason?: string; +} diff --git a/src/customer/dtos/request/update-customer.request.dto.ts b/src/customer/dtos/request/update-customer.request.dto.ts index 0da021a..a9ef346 100644 --- a/src/customer/dtos/request/update-customer.request.dto.ts +++ b/src/customer/dtos/request/update-customer.request.dto.ts @@ -1,32 +1,8 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { IsAbove18 } from '~/core/decorators/validations'; -export class UpdateCustomerRequestDto { - @ApiProperty({ example: 'John' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) - @IsOptional() - firstName!: string; - - @ApiProperty({ example: 'Doe' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) - @IsOptional() - lastName!: string; - - @ApiProperty({ example: 'JO' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) }) - @IsOptional() - countryOfResidence!: string; - - @ApiProperty({ example: '2021-01-01' }) - @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) - @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) - @IsOptional() - dateOfBirth!: Date; - +import { CreateCustomerRequestDto } from './create-customer.request.dto'; +export class UpdateCustomerRequestDto extends PartialType(CreateCustomerRequestDto) { @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' }) @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'customer.profilePictureId' }) }) @IsOptional() diff --git a/src/customer/dtos/response/customer-response.dto.ts b/src/customer/dtos/response/customer-response.dto.ts index a7f9f99..04c849e 100644 --- a/src/customer/dtos/response/customer-response.dto.ts +++ b/src/customer/dtos/response/customer-response.dto.ts @@ -1,17 +1,20 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Customer } from '~/customer/entities'; +import { CustomerStatus, KycStatus } from '~/customer/enums'; import { DocumentMetaResponseDto } from '~/document/dtos/response'; -import { NotificationSettingsResponseDto } from './notification-settings.response.dto'; export class CustomerResponseDto { @ApiProperty() id!: string; @ApiProperty() - customerStatus!: string; + customerStatus!: CustomerStatus; @ApiProperty() - rejectionReason!: string; + kycStatus!: KycStatus; + + @ApiProperty() + rejectionReason!: string | null; @ApiProperty() firstName!: string; @@ -52,15 +55,13 @@ export class CustomerResponseDto { @ApiProperty() isGuardian!: boolean; - @ApiProperty() - notificationSettings!: NotificationSettingsResponseDto; - @ApiPropertyOptional({ type: DocumentMetaResponseDto }) profilePicture!: DocumentMetaResponseDto | null; constructor(customer: Customer) { this.id = customer.id; this.customerStatus = customer.customerStatus; + this.kycStatus = customer.kycStatus; this.rejectionReason = customer.rejectionReason; this.firstName = customer.firstName; this.lastName = customer.lastName; @@ -75,7 +76,7 @@ export class CustomerResponseDto { this.gender = customer.gender; this.isJunior = customer.isJunior; this.isGuardian = customer.isGuardian; - this.notificationSettings = new NotificationSettingsResponseDto(customer.notificationSettings); + this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null; } } diff --git a/src/customer/dtos/response/index.ts b/src/customer/dtos/response/index.ts index 71f8a76..fde43bd 100644 --- a/src/customer/dtos/response/index.ts +++ b/src/customer/dtos/response/index.ts @@ -1,2 +1 @@ export * from './customer-response.dto'; -export * from './notification-settings.response.dto'; diff --git a/src/customer/dtos/response/notification-settings.response.dto.ts b/src/customer/dtos/response/notification-settings.response.dto.ts deleted file mode 100644 index cddd18f..0000000 --- a/src/customer/dtos/response/notification-settings.response.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity'; - -export class NotificationSettingsResponseDto { - @ApiProperty() - isEmailEnabled!: boolean; - - @ApiProperty() - isPushEnabled!: boolean; - - @ApiProperty() - isSmsEnabled!: boolean; - - constructor(notificationSettings: CustomerNotificationSettings) { - this.isEmailEnabled = notificationSettings.isEmailEnabled; - this.isPushEnabled = notificationSettings.isPushEnabled; - this.isSmsEnabled = notificationSettings.isSmsEnabled; - } -} diff --git a/src/customer/entities/customer-notification-settings.entity.ts b/src/customer/entities/customer-notification-settings.entity.ts deleted file mode 100644 index 173734f..0000000 --- a/src/customer/entities/customer-notification-settings.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - BaseEntity, - Column, - CreateDateColumn, - Entity, - JoinColumn, - OneToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -import { Customer } from '~/customer/entities'; - -@Entity('cutsomer_notification_settings') -export class CustomerNotificationSettings extends BaseEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'is_email_enabled', default: false }) - isEmailEnabled!: boolean; - - @Column({ name: 'is_push_enabled', default: false }) - isPushEnabled!: boolean; - - @Column({ name: 'is_sms_enabled', default: false }) - isSmsEnabled!: boolean; - - @OneToOne(() => Customer, (customer) => customer.notificationSettings, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'customer_id' }) - customer!: Customer; - - @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) - createdAt!: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' }) - updatedAt!: Date; -} diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index f3614af..f844962 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -12,18 +12,21 @@ import { Document } from '~/document/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; import { Junior } from '~/junior/entities'; import { User } from '~/user/entities'; -import { CustomerNotificationSettings } from './customer-notification-settings.entity'; +import { CustomerStatus, KycStatus } from '../enums'; @Entity('customers') export class Customer extends BaseEntity { @PrimaryColumn('uuid') id!: string; - @Column('varchar', { length: 255, default: 'PENDING', name: 'customer_status' }) - customerStatus!: string; + @Column('varchar', { length: 255, default: CustomerStatus.PENDING, name: 'customer_status' }) + customerStatus!: CustomerStatus; + + @Column('varchar', { length: 255, default: KycStatus.PENDING, name: 'kyc_status' }) + kycStatus!: KycStatus; @Column('text', { nullable: true, name: 'rejection_reason' }) - rejectionReason!: string; + rejectionReason!: string | null; @Column('varchar', { length: 255, nullable: true, name: 'first_name' }) firstName!: string; @@ -70,12 +73,6 @@ export class Customer extends BaseEntity { @Column('varchar', { name: 'profile_picture_id', nullable: true }) profilePictureId!: string; - @OneToOne(() => CustomerNotificationSettings, (notificationSettings) => notificationSettings.customer, { - cascade: true, - eager: true, - }) - notificationSettings!: CustomerNotificationSettings; - @OneToOne(() => Document, (document) => document.customerPicture, { cascade: true, nullable: true }) @JoinColumn({ name: 'profile_picture_id' }) profilePicture!: Document; @@ -90,6 +87,20 @@ export class Customer extends BaseEntity { @OneToOne(() => Guardian, (guardian) => guardian.customer, { cascade: true }) guardian!: Guardian; + @Column('uuid', { name: 'civil_id_front_id' }) + civilIdFrontId!: string; + + @Column('uuid', { name: 'civil_id_back_id' }) + civilIdBackId!: string; + + @OneToOne(() => Document, (document) => document.customerCivilIdFront) + @JoinColumn({ name: 'civil_id_front_id' }) + civilIdFront!: Document; + + @OneToOne(() => Document, (document) => document.customerCivilIdBack) + @JoinColumn({ name: 'civil_id_back_id' }) + civilIdBack!: Document; + @CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/customer/enums/customer-status.enum.ts b/src/customer/enums/customer-status.enum.ts new file mode 100644 index 0000000..966a584 --- /dev/null +++ b/src/customer/enums/customer-status.enum.ts @@ -0,0 +1,5 @@ +export enum CustomerStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', +} diff --git a/src/customer/enums/index.ts b/src/customer/enums/index.ts new file mode 100644 index 0000000..2bc8b08 --- /dev/null +++ b/src/customer/enums/index.ts @@ -0,0 +1,2 @@ +export * from './customer-status.enum'; +export * from './kyc-status.enum'; diff --git a/src/customer/enums/kyc-status.enum.ts b/src/customer/enums/kyc-status.enum.ts new file mode 100644 index 0000000..2d47e7d --- /dev/null +++ b/src/customer/enums/kyc-status.enum.ts @@ -0,0 +1,5 @@ +export enum KycStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', +} diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index 528cf04..75abe6f 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -1,12 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; -import { Guardian } from '~/guardian/entities/guradian.entity'; import { CreateJuniorRequestDto } from '~/junior/dtos/request'; -import { Junior } from '~/junior/entities'; -import { UpdateNotificationsSettingsRequestDto } from '../dtos/request'; +import { CreateCustomerRequestDto, CustomerFiltersRequestDto } from '../dtos/request'; import { Customer } from '../entities'; -import { CustomerNotificationSettings } from '../entities/customer-notification-settings.entity'; @Injectable() export class CustomerRepository { @@ -20,44 +17,52 @@ export class CustomerRepository { return this.customerRepository.findOne({ where, relations: ['profilePicture'] }); } - createJuniorCustomer(guardianId: string, userId: string, body: CreateJuniorRequestDto) { + createCustomer(userId: string, body: CreateCustomerRequestDto | CreateJuniorRequestDto, isGuardian: boolean = false) { return this.customerRepository.save( this.customerRepository.create({ + id: userId, + userId, + isGuardian, + isJunior: !isGuardian, firstName: body.firstName, lastName: body.lastName, dateOfBirth: body.dateOfBirth, - junior: Junior.create({ - id: userId, - guardianId, - relationship: body.relationship, - civilIdFrontId: body.civilIdFrontId, - civilIdBackId: body.civilIdBackId, - }), - id: userId, - userId, - isJunior: true, - notificationSettings: new CustomerNotificationSettings(), + civilIdFrontId: body.civilIdFrontId, + civilIdBackId: body.civilIdBackId, }), ); } - createGuardianCustomer(userId: string) { - return this.customerRepository.save( - this.customerRepository.create({ - notificationSettings: new CustomerNotificationSettings(), - guardian: Guardian.create({ id: userId }), - id: userId, - userId, - isGuardian: true, - }), - ); + findCustomers(filters: CustomerFiltersRequestDto) { + const query = this.customerRepository.createQueryBuilder('customer'); + + if (filters.name) { + const nameParts = filters.name.trim().split(/\s+/); + console.log(nameParts); + nameParts.length > 1 + ? query.andWhere('customer.firstName LIKE :firstName AND customer.lastName LIKE :lastName', { + firstName: `%${nameParts[0]}%`, + lastName: `%${nameParts[1]}%`, + }) + : query.andWhere('customer.firstName LIKE :name OR customer.lastName LIKE :name', { + name: `%${filters.name.trim()}%`, + }); + } + + if (filters.kycStatus) { + query.andWhere('customer.kycStatus = :kycStatus', { kycStatus: filters.kycStatus }); + } + + query.orderBy('customer.createdAt', 'DESC'); + query.take(filters.size); + query.skip((filters.page - 1) * filters.size); + + return query.getManyAndCount(); } - updateNotificationSettings(customer: Customer, body: UpdateNotificationsSettingsRequestDto) { - customer.notificationSettings = CustomerNotificationSettings.create({ - ...customer.notificationSettings, - ...body, + findCustomerByCivilId(civilIdFrontId: string, civilIdBackId: string) { + return this.customerRepository.findOne({ + where: [{ civilIdFrontId, civilIdBackId }, { civilIdFrontId }, { civilIdBackId }], }); - return this.customerRepository.save(customer); } } diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index c0f732c..0513c22 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -1,9 +1,16 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Transactional } from 'typeorm-transactional'; import { DocumentService, OciService } from '~/document/services'; +import { GuardianService } from '~/guardian/services'; import { CreateJuniorRequestDto } from '~/junior/dtos/request'; -import { DeviceService } from '~/user/services'; -import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; +import { + CreateCustomerRequestDto, + CustomerFiltersRequestDto, + RejectCustomerKycRequestDto, + UpdateCustomerRequestDto, +} from '../dtos/request'; import { Customer } from '../entities'; +import { KycStatus } from '../enums'; import { CustomerRepository } from '../repositories/customer.repository'; @Injectable() @@ -12,26 +19,9 @@ export class CustomerService { constructor( private readonly customerRepository: CustomerRepository, private readonly ociService: OciService, - private readonly deviceService: DeviceService, private readonly documentService: DocumentService, + private readonly guardianService: GuardianService, ) {} - async updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto, deviceId: string) { - this.logger.log(`Updating notification settings for user ${userId}`); - const customer = await this.findCustomerById(userId); - - const notificationSettings = (await this.customerRepository.updateNotificationSettings(customer, data)) - .notificationSettings; - - if (data.isPushEnabled && deviceId) { - this.logger.log(`Updating device ${deviceId} with fcmToken`); - await this.deviceService.updateDevice(deviceId, { - fcmToken: data.fcmToken, - userId: userId, - }); - } - this.logger.log(`Notification settings updated for user ${userId}`); - return notificationSettings; - } async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise { this.logger.log(`Updating customer ${userId}`); @@ -42,14 +32,12 @@ export class CustomerService { return this.findCustomerById(userId); } - createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) { - this.logger.log(`Creating customer for user ${juniorId}`); - return this.customerRepository.createJuniorCustomer(guardianId, juniorId, body); - } + async createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) { + this.logger.log(`Creating junior customer for user ${juniorId}`); - createGuardianCustomer(userId: string) { - this.logger.log(`Creating guardian customer for user ${userId}`); - return this.customerRepository.createGuardianCustomer(userId); + await this.validateCivilIdForCustomer(guardianId, body.civilIdFrontId, body.civilIdBackId); + + return this.customerRepository.createCustomer(juniorId, body, false); } async findCustomerById(id: string) { @@ -69,6 +57,61 @@ export class CustomerService { return customer; } + async approveKycForCustomer(customerId: string) { + const customer = await this.findCustomerById(customerId); + + if (customer.kycStatus === KycStatus.APPROVED) { + this.logger.error(`Customer ${customerId} is already approved`); + throw new BadRequestException('CUSTOMER.ALREADY_APPROVED'); + } + + this.logger.debug(`Approving KYC for customer ${customerId}`); + await this.customerRepository.updateCustomer(customerId, { kycStatus: KycStatus.APPROVED, rejectionReason: null }); + this.logger.log(`KYC approved for customer ${customerId}`); + } + + findCustomers(filters: CustomerFiltersRequestDto) { + this.logger.log(`Finding customers with filters ${JSON.stringify(filters)}`); + return this.customerRepository.findCustomers(filters); + } + + @Transactional() + async createGuardianCustomer(userId: string, body: CreateCustomerRequestDto) { + this.logger.log(`Creating guardian customer for user ${userId}`); + const existingCustomer = await this.customerRepository.findOne({ id: userId }); + + if (existingCustomer) { + this.logger.error(`Customer ${userId} already exists`); + throw new BadRequestException('CUSTOMER.ALRADY_EXISTS'); + } + + await this.validateCivilIdForCustomer(userId, body.civilIdFrontId, body.civilIdBackId); + + const customer = await this.customerRepository.createCustomer(userId, body, true); + this.logger.log(`customer created for user ${userId}`); + + await this.guardianService.createGuardian(customer.id); + this.logger.log(`Guardian created for customer ${customer.id}`); + + return customer; + } + + async rejectKycForCustomer(customerId: string, { reason }: RejectCustomerKycRequestDto) { + const customer = await this.findCustomerById(customerId); + + if (customer.kycStatus === KycStatus.REJECTED) { + this.logger.error(`Customer ${customerId} is already rejected`); + throw new BadRequestException('CUSTOMER.ALREADY_REJECTED'); + } + + this.logger.debug(`Rejecting KYC for customer ${customerId}`); + await this.customerRepository.updateCustomer(customerId, { + kycStatus: KycStatus.REJECTED, + rejectionReason: reason, + }); + this.logger.log(`KYC rejected for customer ${customerId}`); + } + private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) { if (!profilePictureId) return; @@ -86,4 +129,37 @@ export class CustomerService { throw new BadRequestException('DOCUMENT.NOT_CREATED_BY_USER'); } } + + private async validateCivilIdForCustomer(userId: string, civilIdFrontId: string, civilIdBackId: string) { + this.logger.log(`Validating customer documents`); + + if (!civilIdFrontId || !civilIdBackId) { + this.logger.error('Civil id front and back are required'); + throw new BadRequestException('CUSTOMER.CIVIL_ID_REQUIRED'); + } + + const [civilIdFront, civilIdBack] = await Promise.all([ + this.documentService.findDocumentById(civilIdFrontId), + this.documentService.findDocumentById(civilIdBackId), + ]); + + if (!civilIdFront || !civilIdBack) { + this.logger.error('Civil id front or back not found'); + throw new BadRequestException('CUSTOMER.CIVIL_ID_REQUIRED'); + } + + if (civilIdFront.createdById !== userId || civilIdBack.createdById !== userId) { + this.logger.error(`Civil id front or back not created by user with id ${userId}`); + throw new BadRequestException('CUSTOMER.CIVIL_ID_NOT_CREATED_BY_USER'); + } + + const customerWithTheSameId = await this.customerRepository.findCustomerByCivilId(civilIdFrontId, civilIdBackId); + + if (customerWithTheSameId) { + this.logger.error( + `Customer with civil id front ${civilIdFrontId} and civil id back ${civilIdBackId} already exists`, + ); + throw new BadRequestException('CUSTOMER.CIVIL_ID_ALREADY_EXISTS'); + } + } } diff --git a/src/db/migrations/1739868002943-add-kyc-status-to-customer.ts b/src/db/migrations/1739868002943-add-kyc-status-to-customer.ts new file mode 100644 index 0000000..3b87bbc --- /dev/null +++ b/src/db/migrations/1739868002943-add-kyc-status-to-customer.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddKycStatusToCustomer1739868002943 implements MigrationInterface { + name = 'AddKycStatusToCustomer1739868002943'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "customers" ADD "kyc_status" character varying(255) NOT NULL DEFAULT 'PENDING'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "kyc_status"`); + } +} diff --git a/src/db/migrations/1739954239949-add-civilid-to-customers-and-update-notifications-settings.ts b/src/db/migrations/1739954239949-add-civilid-to-customers-and-update-notifications-settings.ts new file mode 100644 index 0000000..8288b1f --- /dev/null +++ b/src/db/migrations/1739954239949-add-civilid-to-customers-and-update-notifications-settings.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCivilidToCustomersAndUpdateNotificationsSettings1739954239949 implements MigrationInterface { + name = 'AddCivilidToCustomersAndUpdateNotificationsSettings1739954239949' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "juniors" DROP CONSTRAINT "FK_4662c4433223c01fe69fc1382f5"`); + await queryRunner.query(`ALTER TABLE "juniors" DROP CONSTRAINT "FK_6a72e1a5758643737cc563b96c7"`); + await queryRunner.query(`ALTER TABLE "juniors" DROP CONSTRAINT "REL_6a72e1a5758643737cc563b96c"`); + await queryRunner.query(`ALTER TABLE "juniors" DROP COLUMN "civil_id_front_id"`); + await queryRunner.query(`ALTER TABLE "juniors" DROP CONSTRAINT "REL_4662c4433223c01fe69fc1382f"`); + await queryRunner.query(`ALTER TABLE "juniors" DROP COLUMN "civil_id_back_id"`); + await queryRunner.query(`ALTER TABLE "customers" ADD "civil_id_front_id" uuid NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "UQ_d5f99c497892ce31598ba19a72c" UNIQUE ("civil_id_front_id")`); + await queryRunner.query(`ALTER TABLE "customers" ADD "civil_id_back_id" uuid NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "UQ_2191662d124c56dd968ba01bf18" UNIQUE ("civil_id_back_id")`); + await queryRunner.query(`ALTER TABLE "users" ADD "is_email_enabled" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "users" ADD "is_push_enabled" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "users" ADD "is_sms_enabled" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_d5f99c497892ce31598ba19a72c" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_2191662d124c56dd968ba01bf18" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_2191662d124c56dd968ba01bf18"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_d5f99c497892ce31598ba19a72c"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "is_sms_enabled"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "is_push_enabled"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "is_email_enabled"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "UQ_2191662d124c56dd968ba01bf18"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "civil_id_back_id"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "UQ_d5f99c497892ce31598ba19a72c"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "civil_id_front_id"`); + await queryRunner.query(`ALTER TABLE "juniors" ADD "civil_id_back_id" uuid NOT NULL`); + await queryRunner.query(`ALTER TABLE "juniors" ADD CONSTRAINT "REL_4662c4433223c01fe69fc1382f" UNIQUE ("civil_id_back_id")`); + await queryRunner.query(`ALTER TABLE "juniors" ADD "civil_id_front_id" uuid NOT NULL`); + await queryRunner.query(`ALTER TABLE "juniors" ADD CONSTRAINT "REL_6a72e1a5758643737cc563b96c" UNIQUE ("civil_id_front_id")`); + await queryRunner.query(`ALTER TABLE "juniors" ADD CONSTRAINT "FK_6a72e1a5758643737cc563b96c7" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "juniors" ADD CONSTRAINT "FK_4662c4433223c01fe69fc1382f5" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/src/db/migrations/1740045960580-create-user-registration-table.ts b/src/db/migrations/1740045960580-create-user-registration-table.ts new file mode 100644 index 0000000..0977081 --- /dev/null +++ b/src/db/migrations/1740045960580-create-user-registration-table.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateUserRegistrationTable1740045960580 implements MigrationInterface { + name = 'CreateUserRegistrationTable1740045960580' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "user_registration_tokens" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "token" character varying(255) NOT NULL, "user_type" character varying(255) NOT NULL, "is_used" boolean NOT NULL DEFAULT false, "expiry_date" TIMESTAMP NOT NULL, "user_id" uuid, "junior_id" uuid, "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_5881556d05b46fc7bd9e3bba935" UNIQUE ("token"), CONSTRAINT "PK_135a2d86443071ff0ba1c14135c" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_5881556d05b46fc7bd9e3bba93" ON "user_registration_tokens" ("token") `); + await queryRunner.query(`ALTER TABLE "user_registration_tokens" ADD CONSTRAINT "FK_57cbbe079a7945d6ed1df114825" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_registration_tokens" ADD CONSTRAINT "FK_e41bec3ed6e549cbf90f57cc344" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_registration_tokens" DROP CONSTRAINT "FK_e41bec3ed6e549cbf90f57cc344"`); + await queryRunner.query(`ALTER TABLE "user_registration_tokens" DROP CONSTRAINT "FK_57cbbe079a7945d6ed1df114825"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5881556d05b46fc7bd9e3bba93"`); + await queryRunner.query(`DROP TABLE "user_registration_tokens"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index b7901b2..d34ea8c 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -19,3 +19,6 @@ export * from './1734861516657-create-gift-entities'; export * from './1734944692999-create-notification-entity-and-edit-device'; export * from './1736414850257-add-flags-to-user-entity'; export * from './1736753223884-add_created_by_to_document_table'; +export * from './1739868002943-add-kyc-status-to-customer'; +export * from './1739954239949-add-civilid-to-customers-and-update-notifications-settings'; +export * from './1740045960580-create-user-registration-table'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index c523d25..7c327f1 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -10,7 +10,7 @@ import { } from 'typeorm'; import { Customer } from '~/customer/entities'; import { Gift } from '~/gift/entities'; -import { Junior, Theme } from '~/junior/entities'; +import { Theme } from '~/junior/entities'; import { SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; import { TaskSubmission } from '~/task/entities/task-submissions.entity'; @@ -37,11 +37,11 @@ export class Document { @OneToOne(() => Customer, (customer) => customer.profilePicture, { onDelete: 'SET NULL' }) customerPicture?: Customer; - @OneToOne(() => Junior, (junior) => junior.civilIdFront, { onDelete: 'SET NULL' }) - juniorCivilIdFront?: User; + @OneToOne(() => Customer, (customer) => customer.civilIdFront, { onDelete: 'SET NULL' }) + customerCivilIdFront?: User; - @OneToOne(() => Junior, (junior) => junior.civilIdBack, { onDelete: 'SET NULL' }) - juniorCivilIdBack?: User; + @OneToOne(() => Customer, (customer) => customer.civilIdBack, { onDelete: 'SET NULL' }) + customerCivilIdBack?: User; @OneToMany(() => Theme, (theme) => theme.avatar) themes?: Theme[]; diff --git a/src/guardian/guardian.module.ts b/src/guardian/guardian.module.ts index e5b53a8..c80c5c1 100644 --- a/src/guardian/guardian.module.ts +++ b/src/guardian/guardian.module.ts @@ -1,8 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Guardian } from './entities/guradian.entity'; +import { GuardianRepository } from './repositories'; +import { GuardianService } from './services'; @Module({ + providers: [GuardianService, GuardianRepository], imports: [TypeOrmModule.forFeature([Guardian])], + exports: [GuardianService], }) export class GuardianModule {} diff --git a/src/guardian/repositories/guardian.repository.ts b/src/guardian/repositories/guardian.repository.ts new file mode 100644 index 0000000..bd5968e --- /dev/null +++ b/src/guardian/repositories/guardian.repository.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Guardian } from '../entities/guradian.entity'; + +@Injectable() +export class GuardianRepository { + constructor(@InjectRepository(Guardian) private readonly guardianRepository: Repository) {} + + createGuardian(customerId: string) { + return this.guardianRepository.save( + this.guardianRepository.create({ + id: customerId, + customerId, + }), + ); + } +} diff --git a/src/guardian/repositories/index.ts b/src/guardian/repositories/index.ts new file mode 100644 index 0000000..789b2cc --- /dev/null +++ b/src/guardian/repositories/index.ts @@ -0,0 +1 @@ +export * from './guardian.repository'; diff --git a/src/guardian/services/guardian.service.ts b/src/guardian/services/guardian.service.ts new file mode 100644 index 0000000..5d185a6 --- /dev/null +++ b/src/guardian/services/guardian.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { GuardianRepository } from '../repositories'; + +@Injectable() +export class GuardianService { + constructor(private readonly guardianRepository: GuardianRepository) {} + + createGuardian(customerId: string) { + return this.guardianRepository.createGuardian(customerId); + } +} diff --git a/src/guardian/services/index.ts b/src/guardian/services/index.ts new file mode 100644 index 0000000..5cdfe9f --- /dev/null +++ b/src/guardian/services/index.ts @@ -0,0 +1 @@ +export * from './guardian.service'; diff --git a/src/i18n/ar/general.json b/src/i18n/ar/general.json index e77f46b..b763a88 100644 --- a/src/i18n/ar/general.json +++ b/src/i18n/ar/general.json @@ -24,6 +24,7 @@ }, "customer": { + "name": "الاسم", "firstName": "الاسم الأول", "lastName": "اسم العائلة", "countryOfResidence": "بلد الإقامة", @@ -31,7 +32,8 @@ "profilePictureId": "معرّف صورة الملف الشخصي", "isEmailEnabled": "هل البريد الإلكتروني مفعّل", "isSmsEnabled": "هل الرسائل النصية مفعّلة", - "isPushEnabled": "هل الإشعارات مفعّلة" + "isPushEnabled": "هل الإشعارات مفعّلة", + "kycStatus": "حالة التحقق من الهوية" }, "junior": { diff --git a/src/i18n/en/general.json b/src/i18n/en/general.json index 82fc26e..3406507 100644 --- a/src/i18n/en/general.json +++ b/src/i18n/en/general.json @@ -25,6 +25,7 @@ }, "customer": { + "name": "Name", "firstName": "First name", "lastName": "Last name", "countryOfResidence": "Country of residence", @@ -32,7 +33,8 @@ "profilePictureId": "Profile picture ID", "isEmailEnabled": "Is Email enabled", "isSmsEnabled": "Is SMS enabled", - "isPushEnabled": "Is Push enabled" + "isPushEnabled": "Is Push enabled", + "kycStatus": "KYC status" }, "junior": { diff --git a/src/junior/dtos/request/create-junior-user.request.dto.ts b/src/junior/dtos/request/create-junior-user.request.dto.ts deleted file mode 100644 index 307f37f..0000000 --- a/src/junior/dtos/request/create-junior-user.request.dto.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsEmail, IsNotEmpty, IsString, Matches } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { COUNTRY_CODE_REGEX } from '~/auth/constants'; -import { IsValidPhoneNumber } from '~/core/decorators/validations'; - -export class CreateJuniorUserRequestDto { - @ApiProperty({ example: '+962' }) - @Matches(COUNTRY_CODE_REGEX, { - message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), - }) - countryCode: string = '+966'; - - @ApiProperty({ example: '787259134' }) - @IsValidPhoneNumber({ - message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), - }) - phoneNumber!: string; - - @ApiProperty({ example: 'John' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) - firstName!: string; - - @ApiProperty({ example: 'Doe' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) - lastName!: string; - - @ApiProperty({ example: '2020-01-01' }) - @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) - dateOfBirth!: Date; - - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - email!: string; -} diff --git a/src/junior/dtos/request/create-junior.request.dto.ts b/src/junior/dtos/request/create-junior.request.dto.ts index 4ac8304..9761fba 100644 --- a/src/junior/dtos/request/create-junior.request.dto.ts +++ b/src/junior/dtos/request/create-junior.request.dto.ts @@ -1,9 +1,40 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsUUID } from 'class-validator'; +import { IsDateString, IsEmail, IsEnum, IsNotEmpty, IsString, IsUUID, Matches } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; import { Relationship } from '~/junior/enums'; -import { CreateJuniorUserRequestDto } from './create-junior-user.request.dto'; -export class CreateJuniorRequestDto extends CreateJuniorUserRequestDto { +export class CreateJuniorRequestDto { + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode: string = '+966'; + + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; + + @ApiProperty({ example: 'John' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) + firstName!: string; + + @ApiProperty({ example: 'Doe' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) + lastName!: string; + + @ApiProperty({ example: '2020-01-01' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) + dateOfBirth!: Date; + + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + email!: string; + @ApiProperty({ example: Relationship.PARENT }) @IsEnum(Relationship, { message: i18n('validation.IsEnum', { path: 'general', property: 'junior.relationship' }) }) relationship!: Relationship; diff --git a/src/junior/dtos/request/index.ts b/src/junior/dtos/request/index.ts index 128c82e..da79731 100644 --- a/src/junior/dtos/request/index.ts +++ b/src/junior/dtos/request/index.ts @@ -1,3 +1,2 @@ -export * from './create-junior-user.request.dto'; export * from './create-junior.request.dto'; export * from './set-theme.request.dto'; diff --git a/src/junior/entities/index.ts b/src/junior/entities/index.ts index 582c397..ff1006a 100644 --- a/src/junior/entities/index.ts +++ b/src/junior/entities/index.ts @@ -1,3 +1,2 @@ -export * from './junior-registration-token.entity'; export * from './junior.entity'; export * from './theme.entity'; diff --git a/src/junior/entities/junior.entity.ts b/src/junior/entities/junior.entity.ts index f4bcdb4..8c27704 100644 --- a/src/junior/entities/junior.entity.ts +++ b/src/junior/entities/junior.entity.ts @@ -12,14 +12,13 @@ import { } from 'typeorm'; import { Allowance } from '~/allowance/entities'; import { Customer } from '~/customer/entities'; -import { Document } from '~/document/entities'; import { Gift } from '~/gift/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; import { MoneyRequest } from '~/money-request/entities'; import { Category, SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; +import { UserRegistrationToken } from '~/user/entities'; import { Relationship } from '../enums'; -import { JuniorRegistrationToken } from './junior-registration-token.entity'; import { Theme } from './theme.entity'; @Entity('juniors') @@ -30,26 +29,12 @@ export class Junior extends BaseEntity { @Column('varchar', { length: 255 }) relationship!: Relationship; - @Column('uuid', { name: 'civil_id_front_id' }) - civilIdFrontId!: string; - - @Column('uuid', { name: 'civil_id_back_id' }) - civilIdBackId!: string; - @Column('uuid', { name: 'customer_id' }) customerId!: string; @Column('uuid', { name: 'guardian_id' }) guardianId!: string; - @OneToOne(() => Document, (document) => document.juniorCivilIdFront) - @JoinColumn({ name: 'civil_id_front_id' }) - civilIdFront!: Document; - - @OneToOne(() => Document, (document) => document.juniorCivilIdBack) - @JoinColumn({ name: 'civil_id_back_id' }) - civilIdBack!: Document; - @OneToOne(() => Customer, (customer) => customer.junior, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'customer_id' }) customer!: Customer; @@ -70,8 +55,8 @@ export class Junior extends BaseEntity { @OneToMany(() => Category, (category) => category.junior) categories!: Category[]; - @OneToMany(() => JuniorRegistrationToken, (token) => token.junior) - registrationTokens!: JuniorRegistrationToken[]; + @OneToMany(() => UserRegistrationToken, (token) => token.junior) + registrationTokens!: UserRegistrationToken[]; @OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.requester) moneyRequests!: MoneyRequest[]; diff --git a/src/junior/junior.module.ts b/src/junior/junior.module.ts index fab48a4..d133888 100644 --- a/src/junior/junior.module.ts +++ b/src/junior/junior.module.ts @@ -4,21 +4,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CustomerModule } from '~/customer/customer.module'; import { UserModule } from '~/user/user.module'; import { JuniorController } from './controllers'; -import { Junior, JuniorRegistrationToken, Theme } from './entities'; -import { JuniorRepository, JuniorTokenRepository } from './repositories'; -import { BranchIoService, JuniorService, JuniorTokenService, QrcodeService } from './services'; +import { Junior, Theme } from './entities'; +import { JuniorRepository } from './repositories'; +import { BranchIoService, JuniorService, QrcodeService } from './services'; @Module({ controllers: [JuniorController], - providers: [ - JuniorService, - JuniorRepository, - JuniorTokenService, - JuniorTokenRepository, - QrcodeService, - BranchIoService, - ], - imports: [TypeOrmModule.forFeature([Junior, Theme, JuniorRegistrationToken]), UserModule, CustomerModule, HttpModule], - exports: [JuniorService, JuniorTokenService], + providers: [JuniorService, JuniorRepository, QrcodeService, BranchIoService], + imports: [TypeOrmModule.forFeature([Junior, Theme]), UserModule, CustomerModule, HttpModule], + exports: [JuniorService], }) export class JuniorModule {} diff --git a/src/junior/repositories/index.ts b/src/junior/repositories/index.ts index 4b1d57a..71277f9 100644 --- a/src/junior/repositories/index.ts +++ b/src/junior/repositories/index.ts @@ -1,2 +1 @@ -export * from './junior-token.repository'; export * from './junior.repository'; diff --git a/src/junior/repositories/junior-token.repository.ts b/src/junior/repositories/junior-token.repository.ts deleted file mode 100644 index de633dc..0000000 --- a/src/junior/repositories/junior-token.repository.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import * as crypto from 'crypto'; -import moment from 'moment'; -import { Repository } from 'typeorm'; -import { JuniorRegistrationToken } from '../entities'; -const TOKEN_LENGTH = 16; -@Injectable() -export class JuniorTokenRepository { - constructor( - @InjectRepository(JuniorRegistrationToken) - private readonly juniorTokenRepository: Repository, - ) {} - generateToken(juniorId: string) { - return this.juniorTokenRepository.save( - this.juniorTokenRepository.create({ - juniorId, - expiryDate: moment().add('15', 'minutes').toDate(), - token: `${moment().unix()}-${crypto.randomBytes(TOKEN_LENGTH).toString('hex')}`, - }), - ); - } - - findByToken(token: string) { - return this.juniorTokenRepository.findOne({ where: { token, isUsed: false } }); - } - - invalidateToken(token: string) { - return this.juniorTokenRepository.update({ token }, { isUsed: true }); - } -} diff --git a/src/junior/repositories/junior.repository.ts b/src/junior/repositories/junior.repository.ts index 77d6b38..316b3d4 100644 --- a/src/junior/repositories/junior.repository.ts +++ b/src/junior/repositories/junior.repository.ts @@ -43,9 +43,12 @@ export class JuniorRepository { return this.juniorRepository.manager.findOne(Theme, { where: { juniorId }, relations: ['avatar'] }); } - findJuniorByCivilId(civilIdFrontId: string, civilIdBackId: string) { - return this.juniorRepository.findOne({ - where: [{ civilIdFrontId, civilIdBackId }, { civilIdFrontId }, { civilIdBackId }], - }); + createJunior(userId: string, data: Partial) { + return this.juniorRepository.save( + this.juniorRepository.create({ + id: userId, + ...data, + }), + ); } } diff --git a/src/junior/services/index.ts b/src/junior/services/index.ts index 38a9d27..1826f29 100644 --- a/src/junior/services/index.ts +++ b/src/junior/services/index.ts @@ -1,4 +1,3 @@ export * from './branch-io.service'; -export * from './junior-token.service'; export * from './junior.service'; export * from './qrcode.service'; diff --git a/src/junior/services/junior-token.service.ts b/src/junior/services/junior-token.service.ts deleted file mode 100644 index e9f260a..0000000 --- a/src/junior/services/junior-token.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { JuniorTokenRepository } from '../repositories'; -import { QrcodeService } from './qrcode.service'; - -@Injectable() -export class JuniorTokenService { - private readonly logger = new Logger(JuniorTokenService.name); - constructor( - private readonly juniorTokenRepository: JuniorTokenRepository, - private readonly qrCodeService: QrcodeService, - ) {} - async generateToken(juniorId: string): Promise { - this.logger.log(`Generating token for junior ${juniorId}`); - const tokenEntity = await this.juniorTokenRepository.generateToken(juniorId); - this.logger.log(`Token generated successfully for junior ${juniorId}`); - return this.qrCodeService.generateQrCode(tokenEntity.token); - } - - async validateToken(token: string) { - this.logger.log(`Validating token ${token}`); - const tokenEntity = await this.juniorTokenRepository.findByToken(token); - - if (!tokenEntity) { - this.logger.error(`Token ${token} not found`); - throw new BadRequestException('TOKEN.INVALID'); - } - - if (tokenEntity.expiryDate < new Date()) { - this.logger.error(`Token ${token} expired`); - throw new BadRequestException('TOKEN.EXPIRED'); - } - - this.logger.log(`Token validated successfully`); - return tokenEntity.juniorId; - } - - invalidateToken(token: string) { - this.logger.log(`Invalidating token ${token}`); - return this.juniorTokenRepository.invalidateToken(token); - } -} diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 12f2b9b..9f09c30 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -4,36 +4,40 @@ import { Roles } from '~/auth/enums'; import { PageOptionsRequestDto } from '~/core/dtos'; import { CustomerService } from '~/customer/services'; import { DocumentService, OciService } from '~/document/services'; +import { UserType } from '~/user/enums'; import { UserService } from '~/user/services'; +import { UserTokenService } from '~/user/services/user-token.service'; import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request'; import { Junior } from '../entities'; import { JuniorRepository } from '../repositories'; -import { JuniorTokenService } from './junior-token.service'; +import { QrcodeService } from './qrcode.service'; @Injectable() export class JuniorService { private readonly logger = new Logger(JuniorService.name); constructor( private readonly juniorRepository: JuniorRepository, - private readonly juniorTokenService: JuniorTokenService, private readonly userService: UserService, + private readonly userTokenService: UserTokenService, private readonly customerService: CustomerService, private readonly documentService: DocumentService, private readonly ociService: OciService, + private readonly qrCodeService: QrcodeService, ) {} @Transactional() async createJuniors(body: CreateJuniorRequestDto, guardianId: string) { this.logger.log(`Creating junior for guardian ${guardianId}`); - const existingUser = await this.userService.findUser([{ email: body.email }, { phoneNumber: body.phoneNumber }]); + const existingUser = await this.userService.findUser([ + { email: body.email }, + { phoneNumber: body.phoneNumber, countryCode: body.countryCode }, + ]); if (existingUser) { this.logger.error(`User with email ${body.email} or phone number ${body.phoneNumber} already exists`); throw new BadRequestException('USER.ALREADY_EXISTS'); } - await this.validateJuniorDocuments(guardianId, body.civilIdFrontId, body.civilIdBackId); - const user = await this.userService.createUser({ email: body.email, countryCode: body.countryCode, @@ -41,11 +45,17 @@ export class JuniorService { roles: [Roles.JUNIOR], }); - await this.customerService.createJuniorCustomer(guardianId, user.id, body); + const customer = await this.customerService.createJuniorCustomer(guardianId, user.id, body); + + await this.juniorRepository.createJunior(user.id, { + guardianId, + relationship: body.relationship, + customerId: customer.id, + }); this.logger.log(`Junior ${user.id} created successfully`); - return this.juniorTokenService.generateToken(user.id); + return this.generateToken(user.id); } async findJuniorById(juniorId: string, withGuardianRelation = false, guardianId?: string) { @@ -90,13 +100,16 @@ export class JuniorService { async validateToken(token: string) { this.logger.log(`Validating token ${token}`); - const juniorId = await this.juniorTokenService.validateToken(token); - return this.findJuniorById(juniorId, true); + const juniorId = await this.userTokenService.validateToken(token, UserType.JUNIOR); + return this.findJuniorById(juniorId!, true); } - generateToken(juniorId: string) { + async generateToken(juniorId: string) { this.logger.log(`Generating token for junior ${juniorId}`); - return this.juniorTokenService.generateToken(juniorId); + + const token = await this.userTokenService.generateToken(juniorId, UserType.JUNIOR); + + return this.qrCodeService.generateQrCode(token); } async doesJuniorBelongToGuardian(guardianId: string, juniorId: string) { @@ -106,38 +119,6 @@ export class JuniorService { return !!junior; } - private async validateJuniorDocuments(userId: string, civilIdFrontId: string, civilIdBackId: string) { - this.logger.log(`Validating junior documents`); - if (!civilIdFrontId || !civilIdBackId) { - this.logger.error('Civil id front and back are required'); - throw new BadRequestException('JUNIOR.CIVIL_ID_REQUIRED'); - } - - const [civilIdFront, civilIdBack] = await Promise.all([ - this.documentService.findDocumentById(civilIdFrontId), - this.documentService.findDocumentById(civilIdBackId), - ]); - - if (!civilIdFront || !civilIdBack) { - this.logger.error('Civil id front or back not found'); - throw new BadRequestException('JUNIOR.CIVIL_ID_REQUIRED'); - } - - if (civilIdFront.createdById !== userId || civilIdBack.createdById !== userId) { - this.logger.error(`Civil id front or back not created by user with id ${userId}`); - throw new BadRequestException('JUNIOR.CIVIL_ID_NOT_CREATED_BY_GUARDIAN'); - } - - const juniorWithSameCivilId = await this.juniorRepository.findJuniorByCivilId(civilIdFrontId, civilIdBackId); - - if (juniorWithSameCivilId) { - this.logger.error( - `Junior with civil id front ${civilIdFrontId} and civil id back ${civilIdBackId} already exists`, - ); - throw new BadRequestException('JUNIOR.CIVIL_ID_ALREADY_EXISTS'); - } - } - private async prepareJuniorImages(juniors: Junior[]) { this.logger.log(`Preparing junior images`); await Promise.all( diff --git a/src/scripts/seed.ts b/src/scripts/seed.ts new file mode 100644 index 0000000..1cdeb96 --- /dev/null +++ b/src/scripts/seed.ts @@ -0,0 +1,49 @@ +console.log('Starting seeder script...'); + +process.on('uncaughtException', (err) => { + console.error('Uncaught Exception:', err); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); +}); +import { NestFactory } from '@nestjs/core'; + +import * as bcrypt from 'bcrypt'; +import { Repository } from 'typeorm'; +import { UserRepository } from '..//user/repositories'; +import { AppModule } from '../app.module'; +import { Roles } from '../auth/enums'; +import { User } from '../user/entities'; +import { initializeTransactionalContext } from 'typeorm-transactional'; +const SALT_ROUNDS = 10; + +async function bootstrap() { + initializeTransactionalContext(); + const app = await NestFactory.createApplicationContext(AppModule); + const userRepository = app.get>('UserRepository'); + const adminEmail = process.env.SUPER_ADMIN_EMAIL; + const adminPassword = process.env.SUPER_ADMIN_PASSWORD; + if (!adminEmail || !adminPassword) { + console.error('Missing SUPER_ADMIN_EMAIL or SUPER_ADMIN_PASSWORD in .env file'); + process.exit(1); + } + const existingAdmin = await userRepository.findOne({ where: { email: adminEmail } }); + if (!existingAdmin) { + const hashedPassword = await bcrypt.hash(adminPassword, SALT_ROUNDS); + const superAdmin = userRepository.create({ + email: adminEmail, + password: hashedPassword, + roles: [Roles.SUPER_ADMIN], + isEmailVerified: true, + isPhoneVerified: true, + }); + await userRepository.save(superAdmin); + console.log('Super Admin created successfully.'); + } else { + console.log('Super Admin already exists.'); + } + await app.close(); +} + +bootstrap(); diff --git a/src/user/controllers/admin.user.controller.ts b/src/user/controllers/admin.user.controller.ts new file mode 100644 index 0000000..660a3eb --- /dev/null +++ b/src/user/controllers/admin.user.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { UserResponseDto } from '~/auth/dtos/response'; +import { Roles } from '~/auth/enums'; +import { AllowedRoles } from '~/common/decorators'; +import { RolesGuard } from '~/common/guards'; +import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators'; +import { CustomParseUUIDPipe } from '~/core/pipes'; +import { ResponseFactory } from '~/core/utils'; +import { CreateCheckerRequestDto, UserFiltersRequestDto } from '../dtos/request'; +import { UserService } from '../services'; + +@Controller('admin/users') +@ApiTags('Users') +@UseGuards(RolesGuard) +@AllowedRoles(Roles.SUPER_ADMIN) +@ApiBearerAuth() +export class AdminUserController { + constructor(private readonly userService: UserService) {} + @Post() + @ApiDataResponse(UserResponseDto) + async createCheckers(@Body() data: CreateCheckerRequestDto) { + const user = await this.userService.createChecker(data); + + return ResponseFactory.data(new UserResponseDto(user)); + } + + @Get() + @ApiDataPageResponse(UserResponseDto) + async findUsers(@Query() filters: UserFiltersRequestDto) { + const [users, count] = await this.userService.findUsers(filters); + + return ResponseFactory.dataPage( + users.map((user) => new UserResponseDto(user)), + { + page: filters.page, + size: filters.size, + itemCount: count, + }, + ); + } + + @Get(':userId') + @ApiDataResponse(UserResponseDto) + async findUserById(@Param('userId', CustomParseUUIDPipe) userId: string) { + const user = await this.userService.findUserOrThrow({ id: userId }); + + return ResponseFactory.data(new UserResponseDto(user)); + } +} diff --git a/src/user/controllers/index.ts b/src/user/controllers/index.ts new file mode 100644 index 0000000..4f30a96 --- /dev/null +++ b/src/user/controllers/index.ts @@ -0,0 +1,2 @@ +export * from './admin.user.controller'; +export * from './user.controller'; diff --git a/src/user/controllers/user.controller.ts b/src/user/controllers/user.controller.ts new file mode 100644 index 0000000..004776d --- /dev/null +++ b/src/user/controllers/user.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Headers, HttpCode, HttpStatus, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { IJwtPayload } from '~/auth/interfaces'; +import { DEVICE_ID_HEADER } from '~/common/constants'; +import { AuthenticatedUser, Public } from '~/common/decorators'; +import { AccessTokenGuard } from '~/common/guards'; +import { SetInternalPasswordRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; +import { UserService } from '../services'; + +@Controller('users') +@ApiTags('Users') +@UseGuards(AccessTokenGuard) +@ApiBearerAuth() +export class UserController { + constructor(private userService: UserService) {} + + @Patch('notifications-settings') + async updateNotificationSettings( + @AuthenticatedUser() user: IJwtPayload, + @Body() data: UpdateNotificationsSettingsRequestDto, + @Headers(DEVICE_ID_HEADER) deviceId: string, + ) { + return this.userService.updateNotificationSettings(user.sub, data, deviceId); + } + + @Post('internal/set-password') + @Public() + @HttpCode(HttpStatus.NO_CONTENT) + async setPassword(@Body() data: SetInternalPasswordRequestDto) { + return this.userService.setCheckerPassword(data); + } +} diff --git a/src/user/dtos/request/create-checker.request.dto.ts b/src/user/dtos/request/create-checker.request.dto.ts new file mode 100644 index 0000000..1ce8ed1 --- /dev/null +++ b/src/user/dtos/request/create-checker.request.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, Matches } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; + +export class CreateCheckerRequestDto { + @ApiProperty({ example: 'checker@example.com' }) + @ApiProperty({ example: 'test@test.com' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + email!: string; + + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; + + @ApiProperty({ example: '797229134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; +} diff --git a/src/user/dtos/request/index.ts b/src/user/dtos/request/index.ts new file mode 100644 index 0000000..30831e1 --- /dev/null +++ b/src/user/dtos/request/index.ts @@ -0,0 +1,4 @@ +export * from './create-checker.request.dto'; +export * from './set-internal-password.request.dto'; +export * from './update-notifications-settings.request.dto'; +export * from './user-filters.request.dto'; diff --git a/src/user/dtos/request/set-internal-password.request.dto.ts b/src/user/dtos/request/set-internal-password.request.dto.ts new file mode 100644 index 0000000..ec49b65 --- /dev/null +++ b/src/user/dtos/request/set-internal-password.request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; + +export class SetInternalPasswordRequestDto { + @ApiProperty() + @IsString({ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.token' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.token' }) }) + token!: string; + + @ApiProperty() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) }) + password!: string; +} diff --git a/src/customer/dtos/request/update-notifications-settings.request.dto.ts b/src/user/dtos/request/update-notifications-settings.request.dto.ts similarity index 73% rename from src/customer/dtos/request/update-notifications-settings.request.dto.ts rename to src/user/dtos/request/update-notifications-settings.request.dto.ts index 47c94de..958d625 100644 --- a/src/customer/dtos/request/update-notifications-settings.request.dto.ts +++ b/src/user/dtos/request/update-notifications-settings.request.dto.ts @@ -3,22 +3,22 @@ import { IsBoolean, IsOptional, IsString, ValidateIf } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; export class UpdateNotificationsSettingsRequestDto { @ApiProperty() - @IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isEmailEnabled' }) }) + @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isEmailEnabled' }) }) @IsOptional() isEmailEnabled!: boolean; @ApiProperty() - @IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isPushEnabled' }) }) + @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isPushEnabled' }) }) @IsOptional() isPushEnabled!: boolean; @ApiProperty() - @IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isSmsEnabled' }) }) + @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isSmsEnabled' }) }) @IsOptional() isSmsEnabled!: boolean; @ApiPropertyOptional() - @IsString({ message: i18n('validation.isString', { path: 'general', property: 'auth.fcmToken' }) }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) @ValidateIf((o) => o.isPushEnabled) fcmToken?: string; } diff --git a/src/user/dtos/request/user-filters.request.dto.ts b/src/user/dtos/request/user-filters.request.dto.ts new file mode 100644 index 0000000..3f04cc5 --- /dev/null +++ b/src/user/dtos/request/user-filters.request.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { Roles } from '~/auth/enums'; +import { PageOptionsRequestDto } from '~/core/dtos'; + +export class UserFiltersRequestDto extends PageOptionsRequestDto { + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.search' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'user.search' }) }) + @IsOptional() + @ApiPropertyOptional({ description: 'Search by email or phone number' }) + search?: string; + + @IsEnum(Roles, { message: i18n('validation.IsEnum', { path: 'general', property: 'user.role' }) }) + @IsOptional() + @ApiPropertyOptional({ enum: Roles, enumName: 'Roles', example: Roles.CHECKER, description: 'Role of the user' }) + role?: string; +} diff --git a/src/user/entities/index.ts b/src/user/entities/index.ts index e9dde3b..013c9ea 100644 --- a/src/user/entities/index.ts +++ b/src/user/entities/index.ts @@ -1,2 +1,3 @@ export * from './device.entity'; +export * from './user-registration-token.entity'; export * from './user.entity'; diff --git a/src/junior/entities/junior-registration-token.entity.ts b/src/user/entities/user-registration-token.entity.ts similarity index 55% rename from src/junior/entities/junior-registration-token.entity.ts rename to src/user/entities/user-registration-token.entity.ts index 7a34e6f..e4a551a 100644 --- a/src/junior/entities/junior-registration-token.entity.ts +++ b/src/user/entities/user-registration-token.entity.ts @@ -9,10 +9,12 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { Junior } from './junior.entity'; +import { Junior } from '~/junior/entities'; +import { UserType } from '../enums'; +import { User } from './user.entity'; -@Entity('junior_registration_tokens') -export class JuniorRegistrationToken extends BaseEntity { +@Entity('user_registration_tokens') +export class UserRegistrationToken extends BaseEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -20,16 +22,26 @@ export class JuniorRegistrationToken extends BaseEntity { @Index() token!: string; + @Column({ type: 'varchar', length: 255, name: 'user_type' }) + userType!: UserType; + @Column({ type: 'boolean', default: false, name: 'is_used' }) isUsed!: boolean; @Column({ type: 'timestamp', name: 'expiry_date' }) expiryDate!: Date; - @Column({ type: 'uuid', name: 'junior_id' }) - juniorId!: string; + @Column({ type: 'uuid', name: 'user_id', nullable: true }) + userId!: string | null; - @ManyToOne(() => Junior, (junior) => junior.registrationTokens) + @Column({ type: 'uuid', name: 'junior_id', nullable: true }) + juniorId!: string | null; + + @ManyToOne(() => User, (user) => user.registrationTokens, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User; + + @ManyToOne(() => Junior, (junior) => junior.registrationTokens, { nullable: true, onDelete: 'CASCADE' }) @JoinColumn({ name: 'junior_id' }) junior!: Junior; diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 51f1b50..ae9a4c1 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -14,6 +14,7 @@ import { Customer } from '~/customer/entities/customer.entity'; import { Document } from '~/document/entities'; import { Roles } from '../../auth/enums'; import { Device } from './device.entity'; +import { UserRegistrationToken } from './user-registration-token.entity'; @Entity('users') export class User extends BaseEntity { @@ -50,6 +51,15 @@ export class User extends BaseEntity { @Column('boolean', { default: false, name: 'is_profile_completed' }) isProfileCompleted!: boolean; + @Column({ name: 'is_email_enabled', default: false }) + isEmailEnabled!: boolean; + + @Column({ name: 'is_push_enabled', default: false }) + isPushEnabled!: boolean; + + @Column({ name: 'is_sms_enabled', default: false }) + isSmsEnabled!: boolean; + @Column('text', { nullable: true, array: true, name: 'roles' }) roles!: Roles[]; @@ -68,6 +78,9 @@ export class User extends BaseEntity { @OneToMany(() => Document, (document) => document.createdBy) createdDocuments!: Document[]; + @OneToMany(() => UserRegistrationToken, (token) => token.user) + registrationTokens!: UserRegistrationToken[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) createdAt!: Date; diff --git a/src/user/enums/index.ts b/src/user/enums/index.ts new file mode 100644 index 0000000..395e15a --- /dev/null +++ b/src/user/enums/index.ts @@ -0,0 +1 @@ +export * from './user-type.enum'; diff --git a/src/user/enums/user-type.enum.ts b/src/user/enums/user-type.enum.ts new file mode 100644 index 0000000..e00c99c --- /dev/null +++ b/src/user/enums/user-type.enum.ts @@ -0,0 +1,4 @@ +export enum UserType { + CHECKER = 'CHECKER', + JUNIOR = 'JUNIOR', +} diff --git a/src/user/repositories/index.ts b/src/user/repositories/index.ts index a12ec2d..b72b57b 100644 --- a/src/user/repositories/index.ts +++ b/src/user/repositories/index.ts @@ -1,2 +1,3 @@ export * from './device.repository'; +export * from './user-token-repository'; export * from './user.repository'; diff --git a/src/user/repositories/user-token-repository.ts b/src/user/repositories/user-token-repository.ts new file mode 100644 index 0000000..3e130db --- /dev/null +++ b/src/user/repositories/user-token-repository.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import * as crypto from 'crypto'; +import moment from 'moment'; +import { Repository } from 'typeorm'; +import { UserRegistrationToken } from '../entities'; +import { UserType } from '../enums'; +const TOKEN_LENGTH = 16; + +@Injectable() +export class UserTokenRepository { + constructor( + @InjectRepository(UserRegistrationToken) + private readonly userTokenRepository: Repository, + ) {} + + generateToken(userId: string, userType: UserType, expiryDate?: Date) { + return this.userTokenRepository.save( + this.userTokenRepository.create({ + userId: userType === UserType.CHECKER ? userId : null, + juniorId: userType === UserType.JUNIOR ? userId : null, + expiryDate: expiryDate ?? moment().add('15', 'minutes').toDate(), + userType, + token: `${moment().unix()}-${crypto.randomBytes(TOKEN_LENGTH).toString('hex')}`, + }), + ); + } + + findByToken(token: string, userType: UserType) { + return this.userTokenRepository.findOne({ where: { token, isUsed: false, userType } }); + } + + invalidateToken(token: string) { + return this.userTokenRepository.update({ token }, { isUsed: true }); + } +} diff --git a/src/user/repositories/user.repository.ts b/src/user/repositories/user.repository.ts index c080917..a8630a0 100644 --- a/src/user/repositories/user.repository.ts +++ b/src/user/repositories/user.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; import { User } from '../../user/entities'; +import { UserFiltersRequestDto } from '../dtos/request'; @Injectable() export class UserRepository { @@ -32,4 +33,24 @@ export class UserRepository { return this.userRepository.save(user); } + + findUsers(filters: UserFiltersRequestDto) { + const queryBuilder = this.userRepository.createQueryBuilder('user'); + + if (filters.role) { + queryBuilder.andWhere(`user.roles @> ARRAY[:role]`, { role: filters.role }); + } + + if (filters.search) { + queryBuilder.andWhere(`user.email ILIKE :search OR user.phoneNumber ILIKE :search`, { + search: `%${filters.search}%`, + }); + } + + queryBuilder.orderBy('user.createdAt', 'DESC'); + queryBuilder.take(filters.size); + queryBuilder.skip((filters.page - 1) * filters.size); + + return queryBuilder.getManyAndCount(); + } } diff --git a/src/user/services/index.ts b/src/user/services/index.ts index d507880..9beb474 100644 --- a/src/user/services/index.ts +++ b/src/user/services/index.ts @@ -1,2 +1,3 @@ export * from './device.service'; +export * from './user-token.service'; export * from './user.service'; diff --git a/src/user/services/user-token.service.ts b/src/user/services/user-token.service.ts new file mode 100644 index 0000000..3a3ee1b --- /dev/null +++ b/src/user/services/user-token.service.ts @@ -0,0 +1,39 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { UserType } from '../enums'; +import { UserTokenRepository } from '../repositories/user-token-repository'; + +@Injectable() +export class UserTokenService { + constructor(private readonly userTokenRepository: UserTokenRepository) {} + private readonly logger = new Logger(UserTokenService.name); + + async generateToken(userId: string, userType: UserType, expiryDate?: Date): Promise { + this.logger.log(`Generating token for user ${userId}`); + const tokenEntity = await this.userTokenRepository.generateToken(userId, userType, expiryDate); + this.logger.log(`Token generated successfully for junior ${userId}`); + return tokenEntity.token; + } + + async validateToken(token: string, userType: UserType): Promise { + this.logger.log(`Validating token ${token}`); + const tokenEntity = await this.userTokenRepository.findByToken(token, userType); + + if (!tokenEntity) { + this.logger.error(`Token ${token} not found`); + throw new BadRequestException('TOKEN.INVALID'); + } + + if (tokenEntity.expiryDate < new Date()) { + this.logger.error(`Token ${token} expired`); + throw new BadRequestException('TOKEN.EXPIRED'); + } + + this.logger.log(`Token validated successfully`); + return tokenEntity.juniorId! || tokenEntity.userId!; + } + + invalidateToken(token: string) { + this.logger.log(`Invalidating token ${token}`); + return this.userTokenRepository.invalidateToken(token); + } +} diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index 65837a2..962dad2 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -1,18 +1,34 @@ import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as bcrypt from 'bcrypt'; +import moment from 'moment'; import { FindOptionsWhere } from 'typeorm'; import { Transactional } from 'typeorm-transactional'; -import { CustomerService } from '~/customer/services'; +import { NotificationsService } from '~/common/modules/notification/services'; import { CreateUnverifiedUserRequestDto } from '../../auth/dtos/request'; import { Roles } from '../../auth/enums'; +import { + CreateCheckerRequestDto, + SetInternalPasswordRequestDto, + UpdateNotificationsSettingsRequestDto, + UserFiltersRequestDto, +} from '../dtos/request'; import { User } from '../entities'; +import { UserType } from '../enums'; import { UserRepository } from '../repositories'; - +import { DeviceService } from './device.service'; +import { UserTokenService } from './user-token.service'; +const SALT_ROUNDS = 10; @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); + private adminPortalUrl = this.configService.getOrThrow('ADMIN_PORTAL_URL'); constructor( private readonly userRepository: UserRepository, - @Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService, + @Inject(forwardRef(() => NotificationsService)) private readonly notificationsService: NotificationsService, + private readonly deviceService: DeviceService, + private readonly userTokenService: UserTokenService, + private readonly configService: ConfigService, ) {} findUser(where: FindOptionsWhere | FindOptionsWhere[]) { @@ -20,6 +36,31 @@ export class UserService { return this.userRepository.findOne(where); } + setEmail(userId: string, email: string) { + this.logger.log(`Setting email ${email} for user ${userId}`); + return this.userRepository.update(userId, { email }); + } + + setPasscode(userId: string, passcode: string, salt: string) { + this.logger.log(`Setting passcode for user ${userId}`); + return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true }); + } + + setPhoneNumber(userId: string, phoneNumber: string, countryCode: string) { + this.logger.log(`Setting phone number ${phoneNumber} for user ${userId}`); + return this.userRepository.update(userId, { phoneNumber, countryCode }); + } + + verifyPhoneNumber(userId: string) { + this.logger.log(`Verifying phone number for user ${userId}`); + return this.userRepository.update(userId, { isPhoneVerified: true }); + } + + findUsers(filters: UserFiltersRequestDto) { + this.logger.log(`Getting users with filters ${JSON.stringify(filters)}`); + return this.userRepository.findUsers(filters); + } + async findUserOrThrow(where: FindOptionsWhere) { this.logger.log(`Finding user with where clause ${JSON.stringify(where)}`); const user = await this.findUser(where); @@ -64,27 +105,50 @@ export class UserService { return user; } - setEmail(userId: string, email: string) { - this.logger.log(`Setting email ${email} for user ${userId}`); - return this.userRepository.update(userId, { email }); - } - - setPasscode(userId: string, passcode: string, salt: string) { - this.logger.log(`Setting passcode for user ${userId}`); - return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true }); - } - - setPhoneNumber(userId: string, phoneNumber: string, countryCode: string) { - this.logger.log(`Setting phone number ${phoneNumber} for user ${userId}`); - return this.userRepository.update(userId, { phoneNumber, countryCode }); - } - - verifyPhoneNumber(userId: string) { - this.logger.log(`Verifying phone number for user ${userId}`); - return this.userRepository.update(userId, { isPhoneVerified: true }); - } - @Transactional() + async createChecker(data: CreateCheckerRequestDto) { + const existingUser = await this.userRepository.findOne([ + { email: data.email }, + { phoneNumber: data.phoneNumber, countryCode: data.countryCode }, + ]); + + if (existingUser) { + throw new BadRequestException('USER.ALREADY_EXISTS'); + } + + const user = await this.createUser({ + ...data, + roles: [Roles.CHECKER], + isProfileCompleted: true, + }); + const ONE_DAY = moment().add(1, 'day').toDate(); + const token = await this.userTokenService.generateToken(user.id, UserType.CHECKER, ONE_DAY); + await this.sendCheckerAccountCreatedEmail(data.email, token); + + 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) { this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`); const user = await this.userRepository.createUser({ @@ -94,12 +158,9 @@ export class UserService { isEmailVerified: true, }); - await this.customerService.createGuardianCustomer(user.id); - return this.findUserOrThrow({ id: user.id }); } - @Transactional() async createAppleUser(appleId: string, email: string) { this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`); const user = await this.userRepository.createUser({ @@ -109,18 +170,24 @@ export class UserService { isEmailVerified: true, }); - await this.customerService.createGuardianCustomer(user.id); - return this.findUserOrThrow({ id: user.id }); } - @Transactional() - async verifyUserAndCreateCustomer(userId: string) { - this.logger.log(`Verifying user ${userId} and creating customer`); - await this.userRepository.update(userId, { isPhoneVerified: true }); - await this.customerService.createGuardianCustomer(userId); + async setCheckerPassword(data: SetInternalPasswordRequestDto) { + const userId = await this.userTokenService.validateToken(data.token, UserType.CHECKER); + this.logger.log(`Setting password for checker ${userId}`); + const salt = bcrypt.genSaltSync(SALT_ROUNDS); + const hashedPasscode = bcrypt.hashSync(data.password, salt); - this.logger.log(`User ${userId} verified and customer created successfully`); - return this.findUserOrThrow({ id: userId }); + return this.userRepository.update(userId, { password: hashedPasscode, salt, isProfileCompleted: true }); + } + + private sendCheckerAccountCreatedEmail(email: string, token: string) { + return this.notificationsService.sendEmailAsync({ + to: email, + template: 'user-invite', + subject: 'Checker Account Created', + data: { inviteLink: `${this.adminPortalUrl}?token=${token}` }, + }); } } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index a781db5..1b86380 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,13 +1,15 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { CustomerModule } from '~/customer/customer.module'; -import { Device, User } from './entities'; -import { DeviceRepository, UserRepository } from './repositories'; -import { DeviceService, UserService } from './services'; +import { NotificationModule } from '~/common/modules/notification/notification.module'; +import { AdminUserController, UserController } from './controllers'; +import { Device, User, UserRegistrationToken } from './entities'; +import { DeviceRepository, UserRepository, UserTokenRepository } from './repositories'; +import { DeviceService, UserService, UserTokenService } from './services'; @Module({ - imports: [TypeOrmModule.forFeature([User, Device]), forwardRef(() => CustomerModule)], - providers: [UserService, DeviceService, UserRepository, DeviceRepository], - exports: [UserService, DeviceService], + imports: [TypeOrmModule.forFeature([User, Device, UserRegistrationToken]), forwardRef(() => NotificationModule)], + providers: [UserService, DeviceService, UserRepository, DeviceRepository, UserTokenRepository, UserTokenService], + exports: [UserService, DeviceService, UserTokenService], + controllers: [UserController, AdminUserController], }) export class UserModule {}