diff --git a/src/customer/controllers/internal.customer.controller.ts b/src/customer/controllers/internal.customer.controller.ts index 1651250..a30e1f4 100644 --- a/src/customer/controllers/internal.customer.controller.ts +++ b/src/customer/controllers/internal.customer.controller.ts @@ -3,7 +3,8 @@ 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 { InternalCustomerDetailsResponseDto } from '../dtos/response'; +import { InternalCustomerListResponse } from '../dtos/response/internal.customer-list.response.dto'; import { CustomerService } from '../services'; @ApiTags('Customers') @@ -15,7 +16,7 @@ export class InternalCustomerController { const [customers, count] = await this.customerService.findCustomers(filters); return ResponseFactory.dataPage( - customers.map((customer) => new CustomerResponseDto(customer)), + customers.map((customer) => new InternalCustomerListResponse(customer)), { page: filters.page, size: filters.size, @@ -26,9 +27,9 @@ export class InternalCustomerController { @Get(':customerId') async findCustomerById(@Param('customerId', CustomParseUUIDPipe) customerId: string) { - const customer = await this.customerService.findCustomerById(customerId); + const customer = await this.customerService.findInternalCustomerById(customerId); - return ResponseFactory.data(new CustomerResponseDto(customer)); + return ResponseFactory.data(new InternalCustomerDetailsResponseDto(customer)); } @Patch(':customerId/approve') diff --git a/src/customer/dtos/request/create-customer.request.dto.ts b/src/customer/dtos/request/create-customer.request.dto.ts index 5e6cfd1..4cec8e4 100644 --- a/src/customer/dtos/request/create-customer.request.dto.ts +++ b/src/customer/dtos/request/create-customer.request.dto.ts @@ -1,39 +1,69 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { IsAbove18 } from '~/core/decorators/validations'; +import { Gender } from '~/customer/enums'; 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: 'MALE' }) + @IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) }) + gender!: Gender; + @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: '999300024' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.nationalId' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.nationalId' }) }) + nationalId!: string; + + @ApiProperty({ example: '2021-01-01' }) + @IsDateString( + {}, + { message: i18n('validation.IsDateString', { path: 'general', property: 'junior.nationalIdExpiry' }) }, + ) + nationalIdExpiry!: Date; + + @ApiProperty({ example: 'Employee' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.sourceOfIncome' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.sourceOfIncome' }) }) + sourceOfIncome!: string; + + @ApiProperty({ example: 'Accountant' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.profession' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.profession' }) }) + profession!: string; + + @ApiProperty({ example: 'Finance' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.professionType' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.professionType' }) }) + professionType!: string; + + @ApiProperty({ example: false }) + @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'junior.isPep' }) }) + isPep!: boolean; + @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/response/index.ts b/src/customer/dtos/response/index.ts index fde43bd..be77608 100644 --- a/src/customer/dtos/response/index.ts +++ b/src/customer/dtos/response/index.ts @@ -1 +1,3 @@ export * from './customer-response.dto'; +export * from './internal.customer-details.response.dto'; +export * from './internal.customer-list.response.dto'; diff --git a/src/customer/dtos/response/internal.customer-details.response.dto.ts b/src/customer/dtos/response/internal.customer-details.response.dto.ts new file mode 100644 index 0000000..ee8c985 --- /dev/null +++ b/src/customer/dtos/response/internal.customer-details.response.dto.ts @@ -0,0 +1,89 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Customer } from '~/customer/entities'; +import { CustomerStatus, KycStatus } from '~/customer/enums'; +import { DocumentMetaResponseDto } from '~/document/dtos/response'; + +export class InternalCustomerDetailsResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty() + customerStatus!: CustomerStatus; + + @ApiProperty() + kycStatus!: KycStatus; + + @ApiProperty() + rejectionReason!: string | null; + + @ApiProperty() + fullName!: string; + + @ApiProperty() + phoneNumber!: string; + + @ApiProperty() + dateOfBirth!: Date; + + @ApiProperty() + nationalId!: string; + + @ApiProperty() + nationalIdExpiry!: Date; + + @ApiProperty() + countryOfResidence!: string; + + @ApiProperty() + sourceOfIncome!: string; + + @ApiProperty() + profession!: string; + + @ApiProperty() + professionType!: string; + + @ApiProperty() + isPep!: boolean; + + @ApiProperty() + gender!: string; + + @ApiProperty() + isJunior!: boolean; + + @ApiProperty() + isGuardian!: boolean; + + @ApiProperty({ type: DocumentMetaResponseDto }) + civilIdFront!: DocumentMetaResponseDto; + + @ApiProperty({ type: DocumentMetaResponseDto }) + civilIdBack!: DocumentMetaResponseDto; + + @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.fullName = `${customer.firstName} ${customer.lastName}`; + this.phoneNumber = customer.user.fullPhoneNumber; + this.dateOfBirth = customer.dateOfBirth; + this.nationalId = customer.nationalId; + this.nationalIdExpiry = customer.nationalIdExpiry; + this.countryOfResidence = customer.countryOfResidence; + this.sourceOfIncome = customer.sourceOfIncome; + this.profession = customer.profession; + this.professionType = customer.professionType; + this.isPep = customer.isPep; + this.gender = customer.gender; + this.isJunior = customer.isJunior; + this.isGuardian = customer.isGuardian; + this.civilIdFront = new DocumentMetaResponseDto(customer.civilIdFront); + this.civilIdBack = new DocumentMetaResponseDto(customer.civilIdBack); + this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null; + } +} diff --git a/src/customer/dtos/response/internal.customer-list.response.dto.ts b/src/customer/dtos/response/internal.customer-list.response.dto.ts new file mode 100644 index 0000000..20a55cf --- /dev/null +++ b/src/customer/dtos/response/internal.customer-list.response.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Customer } from '~/customer/entities'; +import { CustomerStatus, KycStatus } from '~/customer/enums'; + +export class InternalCustomerListResponse { + @ApiProperty() + id!: string; + + @ApiProperty() + fullName!: string; + + @ApiProperty() + phoneNumber!: string; + + @ApiProperty() + customerStatus!: CustomerStatus; + + @ApiProperty() + kycStatus!: KycStatus; + + @ApiProperty() + dateOfBirth!: Date; + + @ApiProperty() + gender!: string; + + @ApiProperty() + isJunior!: boolean; + + @ApiProperty() + isGuardian!: boolean; + + constructor(customer: Customer) { + this.id = customer.id; + this.fullName = `${customer.firstName} ${customer.lastName}`; + this.phoneNumber = customer.user?.fullPhoneNumber; + this.customerStatus = customer.customerStatus; + this.kycStatus = customer.kycStatus; + this.dateOfBirth = customer.dateOfBirth; + this.gender = customer.gender; + this.isGuardian = customer.isGuardian; + this.isJunior = customer.isJunior; + } +} diff --git a/src/customer/enums/gender.enum.ts b/src/customer/enums/gender.enum.ts new file mode 100644 index 0000000..994cd07 --- /dev/null +++ b/src/customer/enums/gender.enum.ts @@ -0,0 +1,4 @@ +export enum Gender { + MALE = 'MALE', + FEMALE = 'FEMALE', +} diff --git a/src/customer/enums/index.ts b/src/customer/enums/index.ts index 2bc8b08..ea026d8 100644 --- a/src/customer/enums/index.ts +++ b/src/customer/enums/index.ts @@ -1,2 +1,3 @@ export * from './customer-status.enum'; +export * from './gender.enum'; export * from './kyc-status.enum'; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index 75abe6f..516cfea 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; -import { CreateJuniorRequestDto } from '~/junior/dtos/request'; -import { CreateCustomerRequestDto, CustomerFiltersRequestDto } from '../dtos/request'; +import { CustomerFiltersRequestDto } from '../dtos/request'; import { Customer } from '../entities'; @Injectable() @@ -14,10 +13,13 @@ export class CustomerRepository { } findOne(where: FindOptionsWhere) { - return this.customerRepository.findOne({ where, relations: ['profilePicture'] }); + return this.customerRepository.findOne({ + where, + relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack'], + }); } - createCustomer(userId: string, body: CreateCustomerRequestDto | CreateJuniorRequestDto, isGuardian: boolean = false) { + createCustomer(userId: string, body: Partial, isGuardian: boolean = false) { return this.customerRepository.save( this.customerRepository.create({ id: userId, @@ -27,6 +29,15 @@ export class CustomerRepository { firstName: body.firstName, lastName: body.lastName, dateOfBirth: body.dateOfBirth, + gender: body.gender, + countryOfResidence: body.countryOfResidence, + nationalId: body.nationalId, + nationalIdExpiry: body.nationalIdExpiry, + sourceOfIncome: body.sourceOfIncome, + profession: body.profession, + professionType: body.professionType, + isPep: body.isPep, + civilIdFrontId: body.civilIdFrontId, civilIdBackId: body.civilIdBackId, }), @@ -35,6 +46,8 @@ export class CustomerRepository { findCustomers(filters: CustomerFiltersRequestDto) { const query = this.customerRepository.createQueryBuilder('customer'); + query.leftJoinAndSelect('customer.profilePicture', 'profilePicture'); + query.leftJoinAndSelect('customer.user', 'user'); if (filters.name) { const nameParts = filters.name.trim().split(/\s+/); diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 0513c22..76b8905 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -50,13 +50,26 @@ export class CustomerService { } if (customer.profilePicture) { - this.logger.log(`Generating pre-signed url for profile picture of customer ${id}`); customer.profilePicture.url = await this.ociService.generatePreSignedUrl(customer.profilePicture); } this.logger.log(`Customer ${id} found successfully`); return customer; } + async findInternalCustomerById(id: string) { + this.logger.log(`Finding internal customer ${id}`); + const customer = await this.customerRepository.findOne({ id }); + + if (!customer) { + this.logger.error(`Internal customer ${id} not found`); + throw new BadRequestException('CUSTOMER.NOT_FOUND'); + } + + await this.prepareCustomerDocuments(customer); + this.logger.log(`Internal customer ${id} found successfully`); + return customer; + } + async approveKycForCustomer(customerId: string) { const customer = await this.findCustomerById(customerId); @@ -162,4 +175,24 @@ export class CustomerService { throw new BadRequestException('CUSTOMER.CIVIL_ID_ALREADY_EXISTS'); } } + + private async prepareCustomerDocuments(customer: Customer) { + const promises = []; + + promises.push(this.ociService.generatePreSignedUrl(customer.civilIdFront)); + promises.push(this.ociService.generatePreSignedUrl(customer.civilIdBack)); + + if (customer.profilePicture) { + promises.push(this.ociService.generatePreSignedUrl(customer.profilePicture)); + } + + const [civilIdFrontUrl, civilIdBackUrl, profilePictureUrl] = await Promise.all(promises); + if (customer.profilePicture) { + customer.profilePicture.url = profilePictureUrl; + } + + customer.civilIdFront.url = civilIdFrontUrl; + customer.civilIdBack.url = civilIdBackUrl; + return customer; + } } diff --git a/src/junior/dtos/request/create-junior.request.dto.ts b/src/junior/dtos/request/create-junior.request.dto.ts index 9761fba..908304f 100644 --- a/src/junior/dtos/request/create-junior.request.dto.ts +++ b/src/junior/dtos/request/create-junior.request.dto.ts @@ -3,6 +3,7 @@ import { IsDateString, IsEmail, IsEnum, IsNotEmpty, IsString, IsUUID, Matches } import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { COUNTRY_CODE_REGEX } from '~/auth/constants'; import { IsValidPhoneNumber } from '~/core/decorators/validations'; +import { Gender } from '~/customer/enums'; import { Relationship } from '~/junior/enums'; export class CreateJuniorRequestDto { @ApiProperty({ example: '+962' }) @@ -27,6 +28,10 @@ export class CreateJuniorRequestDto { @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) lastName!: string; + @ApiProperty({ example: 'MALE' }) + @IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) }) + gender!: string; + @ApiProperty({ example: '2020-01-01' }) @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) dateOfBirth!: Date; diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index ae9a4c1..a18173d 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -90,4 +90,8 @@ export class User extends BaseEntity { get isPasswordSet(): boolean { return !!this.password; } + + get fullPhoneNumber(): string { + return `${this.countryCode}${this.phoneNumber}`; + } }