feat: adding enhancment for zod admin portal kyc and add customer additional fields

This commit is contained in:
Abdalhamid Alhamad
2025-03-02 10:49:58 +03:00
parent dae9cb6323
commit 54ce5b022d
11 changed files with 242 additions and 16 deletions

View File

@ -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')

View File

@ -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;
}

View File

@ -1 +1,3 @@
export * from './customer-response.dto';
export * from './internal.customer-details.response.dto';
export * from './internal.customer-list.response.dto';

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,4 @@
export enum Gender {
MALE = 'MALE',
FEMALE = 'FEMALE',
}

View File

@ -1,2 +1,3 @@
export * from './customer-status.enum';
export * from './gender.enum';
export * from './kyc-status.enum';

View File

@ -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<Customer>) {
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<Customer>, 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+/);

View File

@ -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;
}
}

View File

@ -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;

View File

@ -90,4 +90,8 @@ export class User extends BaseEntity {
get isPasswordSet(): boolean {
return !!this.password;
}
get fullPhoneNumber(): string {
return `${this.countryCode}${this.phoneNumber}`;
}
}