Compare commits

..

16 Commits

Author SHA1 Message Date
2a62787c3b Merge pull request #63 from HamzaSha1/feature/kyc-onboarding-metadata
refactor: remove unused PoiValidationRule class from KycMetadataRespo
2025-11-18 15:16:33 +03:00
91dea22f45 refactor: remove unused PoiValidationRule class from KycMetadataResponseDto 2025-11-18 15:14:47 +03:00
ef28c75f9b Merge pull request #62 from HamzaSha1/feature/kyc-onboarding-metadata
feat: add KYC onboarding metadata endpoint with POI validation
2025-11-18 15:06:50 +03:00
c007ac584f feat: add KYC onboarding metadata endpoint with POI validation 2025-11-18 15:03:42 +03:00
d2d83549b2 Merge pull request #61 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Enhance profile picture handling in JuniorService to ensure foreign
2025-11-09 12:43:54 +03:00
506974afc8 Enhance profile picture handling in JuniorService to ensure foreign key consistency and validate document ownership before assignment. 2025-11-09 12:42:48 +03:00
95f8cfbfdf Merge pull request #60 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Update return value in updateJunior method to fetch updated junior dtails by ID instead of returning the junior object directly.
2025-11-09 12:26:44 +03:00
8b00cda23d Update return value in updateJunior method to fetch updated junior details by ID instead of returning the junior object directly. 2025-11-09 12:25:37 +03:00
12cc88a50e Merge pull request #59 from HamzaSha1/money-request-to-use-the-parint-account
Refactor balance check in increaseReservedBalance method to delegate …
2025-11-02 12:41:51 +03:00
2172051093 Refactor balance check in increaseReservedBalance method to delegate validation to the caller, improving clarity and responsibility separation. 2025-11-02 12:41:16 +03:00
a6a573957c Merge pull request #58 from HamzaSha1/money-request-to-use-the-parint-account
add more loggs
2025-11-02 12:35:31 +03:00
d6fb5f48d9 add more loggs 2025-11-02 12:34:41 +03:00
b0011eb7cc Merge pull request #57 from HamzaSha1/money-request-to-use-the-parint-account
Money request to use the parint account
2025-11-02 12:07:13 +03:00
99af65a300 money-request to use the parent card 2025-11-02 11:57:41 +03:00
0c9b40132a Merge pull request #56 from HamzaSha1/ZOD-344-after-a-child-completes-registration-using-the-qr-code-the-same-qr-code-remains-valid-and-allows-the-child-to-register-again-instead-of-expiring
ZOD-344-Add QR code validation error handling and localization support
2025-11-02 11:02:25 +03:00
3b295ea79f ZOD-344-Add QR code validation error handling and localization support
- Introduced new error handling for already used or expired QR codes in JuniorService.
- Added corresponding localization entries in Arabic and English app.json files for QR code validation messages.
2025-11-02 10:52:43 +03:00
20 changed files with 368 additions and 16 deletions

View File

@ -64,9 +64,8 @@ export class AccountService {
} }
increaseReservedBalance(account: Account, amount: number) { increaseReservedBalance(account: Account, amount: number) {
if (account.balance < account.reservedBalance + amount) { // Balance check is performed by the caller (e.g., transferToChild)
throw new UnprocessableEntityException('CARD.INSUFFICIENT_BALANCE'); // to ensure correct account (guardian vs child) is validated
}
return this.accountRepository.increaseReservedBalance(account.id, amount); return this.accountRepository.increaseReservedBalance(account.id, amount);
} }

View File

@ -148,7 +148,18 @@ export class CardService {
async transferToChild(juniorId: string, amount: number) { async transferToChild(juniorId: string, amount: number) {
const card = await this.getCardByCustomerId(juniorId); const card = await this.getCardByCustomerId(juniorId);
if (amount > card.account.balance - card.account.reservedBalance) { this.logger.debug(`Transfer to child - juniorId: ${juniorId}, parentId: ${card.parentId}, cardId: ${card.id}`);
this.logger.debug(`Card account - balance: ${card.account.balance}, reserved: ${card.account.reservedBalance}`);
const fundingAccount = card.parentId
? await this.accountService.getAccountByCustomerId(card.parentId)
: card.account;
this.logger.debug(`Funding account - balance: ${fundingAccount.balance}, reserved: ${fundingAccount.reservedBalance}, available: ${fundingAccount.balance - fundingAccount.reservedBalance}`);
this.logger.debug(`Amount requested: ${amount}`);
if (amount > fundingAccount.balance - fundingAccount.reservedBalance) {
this.logger.error(`Insufficient balance - requested: ${amount}, available: ${fundingAccount.balance - fundingAccount.reservedBalance}`);
throw new BadRequestException('CARD.INSUFFICIENT_BALANCE'); throw new BadRequestException('CARD.INSUFFICIENT_BALANCE');
} }
@ -156,7 +167,7 @@ export class CardService {
await Promise.all([ await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()), this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()),
this.updateCardLimit(card.id, finalAmount.toNumber()), this.updateCardLimit(card.id, finalAmount.toNumber()),
this.accountService.increaseReservedBalance(card.account, amount), this.accountService.increaseReservedBalance(fundingAccount, amount),
this.transactionService.createInternalChildTransaction(card.id, amount), this.transactionService.createInternalChildTransaction(card.id, amount),
]); ]);

View File

@ -42,10 +42,18 @@ export class TransactionService {
const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees);
if (card.customerType === CustomerType.CHILD) { if (card.customerType === CustomerType.CHILD) {
await Promise.all([ if (card.parentId) {
this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()), const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
this.accountService.decrementReservedBalance(card.account, total.toNumber()), await Promise.all([
]); this.accountService.decreaseAccountBalance(parentAccount.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(parentAccount, total.toNumber()),
]);
} else {
await Promise.all([
this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(card.account, total.toNumber()),
]);
}
} else { } else {
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
} }

View File

@ -1,12 +1,12 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators'; import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards'; import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { InitiateKycRequestDto } from '../dtos/request'; import { InitiateKycRequestDto } from '../dtos/request';
import { CustomerResponseDto, InitiateKycResponseDto } from '../dtos/response'; import { CustomerResponseDto, InitiateKycResponseDto, KycMetadataResponseDto } from '../dtos/response';
import { CustomerService } from '../services'; import { CustomerService } from '../services';
@Controller('customers') @Controller('customers')
@ -32,4 +32,14 @@ export class CustomerController {
return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber)); return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber));
} }
@Get('/kyc/onboard-metadata')
@UseGuards(AccessTokenGuard)
@ApiOperation({ summary: 'Get KYC onboarding form metadata' })
@ApiDataResponse(KycMetadataResponseDto)
async getKycMetadata() {
const metadata = await this.customerService.getKycOnboardMetadata();
return ResponseFactory.data(metadata);
}
} }

View File

@ -6,12 +6,12 @@ import { UserModule } from '~/user/user.module';
import { CustomerController } from './controllers'; import { CustomerController } from './controllers';
import { Customer } from './entities'; import { Customer } from './entities';
import { CustomerRepository } from './repositories/customer.repository'; import { CustomerRepository } from './repositories/customer.repository';
import { CustomerService } from './services'; import { CustomerService, MetadataService } from './services';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule], imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule],
controllers: [CustomerController], controllers: [CustomerController],
providers: [CustomerService, CustomerRepository], providers: [CustomerService, CustomerRepository, MetadataService],
exports: [CustomerService], exports: [CustomerService],
}) })
export class CustomerModule {} export class CustomerModule {}

View File

@ -1,2 +1,3 @@
export * from './customer.response.dto'; export * from './customer.response.dto';
export * from './initiate-kyc.response.dto'; export * from './initiate-kyc.response.dto';
export * from './kyc-metadata.response.dto';

View File

@ -0,0 +1,12 @@
export class MetadataOptionDto {
value!: string;
label!: string;
}
export class KycMetadataResponseDto {
poiTypes!: MetadataOptionDto[];
jobSectors!: MetadataOptionDto[];
incomeSources!: MetadataOptionDto[];
jobCategories!: MetadataOptionDto[];
incomeRanges!: MetadataOptionDto[];
}

View File

@ -0,0 +1,8 @@
export enum IncomeRange {
BELOW_2000 = 'SAR 2,000 and below',
RANGE_2000_5000 = 'SAR 2,000 to 5,000',
RANGE_5000_10000 = 'SAR 5,000 to 10,000',
RANGE_10000_20000 = 'SAR 10,000 to 20,000',
ABOVE_20000 = 'SAR 20,000 and above',
}

View File

@ -0,0 +1,9 @@
export enum IncomeSource {
SALARY = 'SALARY',
ANCESTRAL = 'ANCESTRAL',
REAL_ESTATE = 'REAL_ESTATE',
INVESTMENT_RETURNS = 'INVESTMENT_RETURNS',
RENTAL_INCOME = 'RENTAL_INCOME',
OTHER = 'OTHER',
}

View File

@ -1,3 +1,8 @@
export * from './customer-status.enum'; export * from './customer-status.enum';
export * from './gender.enum'; export * from './gender.enum';
export * from './kyc-status.enum'; export * from './kyc-status.enum';
export * from './poi-type.enum';
export * from './job-sector.enum';
export * from './income-source.enum';
export * from './job-category.enum';
export * from './income-range.enum';

View File

@ -0,0 +1,57 @@
export enum JobCategory {
ASSISTANT_MINISTER = 'ASSISTANT_MINISTER',
DEPUTY_MINISTER = 'DEPUTY_MINISTER',
UNDER_SECRETARY = 'UNDER_SECRETARY',
GENERAL_MANAGER = 'GENERAL_MANAGER',
CHAIRMAN = 'CHAIRMAN',
MANAGER = 'MANAGER',
PROFESSOR = 'PROFESSOR',
HEAD_OF_COURT = 'HEAD_OF_COURT',
JUDGE = 'JUDGE',
LAWYER = 'LAWYER',
SCIENTIST = 'SCIENTIST',
NOTARY = 'NOTARY',
BUSINESSMAN = 'BUSINESSMAN',
MERCHANT = 'MERCHANT',
PHARMACIST = 'PHARMACIST',
DOCTOR = 'DOCTOR',
MEDICAL_TECHNICIAN = 'MEDICAL_TECHNICIAN',
NURSE = 'NURSE',
ENGINEER = 'ENGINEER',
CHEMIST = 'CHEMIST',
CONTRACTOR = 'CONTRACTOR',
AUDITOR_ACCOUNTANT = 'AUDITOR_ACCOUNTANT',
RESEARCHER = 'RESEARCHER',
ACCOUNTANT = 'ACCOUNTANT',
JOURNALIST = 'JOURNALIST',
DESIGNER = 'DESIGNER',
COMPUTER_SPECIALIST = 'COMPUTER_SPECIALIST',
TRANSLATOR = 'TRANSLATOR',
TEACHER = 'TEACHER',
PILOT = 'PILOT',
HOST = 'HOST',
OFFICER = 'OFFICER',
SOLDIER = 'SOLDIER',
RETIRED = 'RETIRED',
SALESMAN = 'SALESMAN',
AUTHOR = 'AUTHOR',
CRAFTSMAN = 'CRAFTSMAN',
SECURITY = 'SECURITY',
LABORER = 'LABORER',
DRIVER = 'DRIVER',
FARMER = 'FARMER',
HOUSEWIFE = 'HOUSEWIFE',
DIPLOMAT = 'DIPLOMAT',
STUDENT = 'STUDENT',
FREELANCER = 'FREELANCER',
SHEPHERD = 'SHEPHERD',
HOUSEMAID_OR_BABYSITTER = 'HOUSEMAID_OR_BABYSITTER',
CAPTAIN = 'CAPTAIN',
AMBASSADOR = 'AMBASSADOR',
MARKETING = 'MARKETING',
CONSULTING = 'CONSULTING',
SUPERVISOR = 'SUPERVISOR',
BANKER = 'BANKER',
BODYGUARD_OR_PERSONAL_ASSISTANT = 'BODYGUARD_OR_PERSONAL_ASSISTANT',
}

View File

@ -0,0 +1,12 @@
export enum JobSector {
GOVERNMENT_SECTOR = 'GOVERNMENT_SECTOR',
HOME_MAKER = 'HOME_MAKER',
MILITARY = 'MILITARY',
PRIVATE_SECTOR = 'PRIVATE_SECTOR',
RETIRED = 'RETIRED',
SELF_EMPLOYED = 'SELF_EMPLOYED',
STUDENT = 'STUDENT',
HOUSEHOLD_LABOR = 'HOUSEHOLD_LABOR',
UNEMPLOYED = 'UNEMPLOYED',
}

View File

@ -0,0 +1,5 @@
export enum PoiType {
IQA = 'IQA', // Iqama (Resident ID)
NAT = 'NAT', // National ID
}

View File

@ -12,6 +12,7 @@ import { InitiateKycRequestDto } from '../dtos/request';
import { Customer } from '../entities'; import { Customer } from '../entities';
import { Gender, KycStatus } from '../enums'; import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository'; import { CustomerRepository } from '../repositories/customer.repository';
import { MetadataService } from './metadata.service';
@Injectable() @Injectable()
export class CustomerService { export class CustomerService {
@ -20,6 +21,7 @@ export class CustomerService {
private readonly customerRepository: CustomerRepository, private readonly customerRepository: CustomerRepository,
private readonly guardianService: GuardianService, private readonly guardianService: GuardianService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
private readonly metadataService: MetadataService,
) {} ) {}
async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> { async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> {
@ -149,6 +151,11 @@ export class CustomerService {
return this.findCustomerById(userId); return this.findCustomerById(userId);
} }
getKycOnboardMetadata() {
this.logger.log('Getting KYC onboard metadata');
return this.metadataService.getKycOnboardMetadata();
}
// TO BE REMOVED: This function is for testing only and will be removed // TO BE REMOVED: This function is for testing only and will be removed
private generateSaudiPhoneNumber(): string { private generateSaudiPhoneNumber(): string {
// Saudi mobile numbers are 9 digits, always starting with '5' // Saudi mobile numbers are 9 digits, always starting with '5'

View File

@ -1 +1,2 @@
export * from './customer.service'; export * from './customer.service';
export * from './metadata.service';

View File

@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { IncomeRange, IncomeSource, JobCategory, JobSector, PoiType } from '../enums';
import { KycMetadataResponseDto, MetadataOptionDto } from '../dtos/response';
@Injectable()
export class MetadataService {
getKycOnboardMetadata(): KycMetadataResponseDto {
return {
poiTypes: this.enumToOptions(PoiType, {
[PoiType.IQA]: 'Iqama (Resident ID)',
[PoiType.NAT]: 'National ID',
}),
jobSectors: this.enumToOptions(JobSector, {
[JobSector.GOVERNMENT_SECTOR]: 'Government Sector',
[JobSector.HOME_MAKER]: 'Home Maker',
[JobSector.MILITARY]: 'Military',
[JobSector.PRIVATE_SECTOR]: 'Private Sector',
[JobSector.RETIRED]: 'Retired',
[JobSector.SELF_EMPLOYED]: 'Self Employed',
[JobSector.STUDENT]: 'Student',
[JobSector.HOUSEHOLD_LABOR]: 'Household Labor',
[JobSector.UNEMPLOYED]: 'Unemployed',
}),
incomeSources: this.enumToOptions(IncomeSource, {
[IncomeSource.SALARY]: 'Salary',
[IncomeSource.ANCESTRAL]: 'Ancestral/Inheritance',
[IncomeSource.REAL_ESTATE]: 'Real Estate',
[IncomeSource.INVESTMENT_RETURNS]: 'Investment Returns',
[IncomeSource.RENTAL_INCOME]: 'Rental Income',
[IncomeSource.OTHER]: 'Other',
}),
jobCategories: this.enumToOptions(JobCategory, {
[JobCategory.ASSISTANT_MINISTER]: 'Assistant Minister',
[JobCategory.DEPUTY_MINISTER]: 'Deputy Minister',
[JobCategory.UNDER_SECRETARY]: 'Under Secretary',
[JobCategory.GENERAL_MANAGER]: 'General Manager',
[JobCategory.CHAIRMAN]: 'Chairman',
[JobCategory.MANAGER]: 'Manager',
[JobCategory.PROFESSOR]: 'Professor',
[JobCategory.HEAD_OF_COURT]: 'Head of Court',
[JobCategory.JUDGE]: 'Judge',
[JobCategory.LAWYER]: 'Lawyer',
[JobCategory.SCIENTIST]: 'Scientist',
[JobCategory.NOTARY]: 'Notary',
[JobCategory.BUSINESSMAN]: 'Businessman',
[JobCategory.MERCHANT]: 'Merchant',
[JobCategory.PHARMACIST]: 'Pharmacist',
[JobCategory.DOCTOR]: 'Doctor',
[JobCategory.MEDICAL_TECHNICIAN]: 'Medical Technician',
[JobCategory.NURSE]: 'Nurse',
[JobCategory.ENGINEER]: 'Engineer',
[JobCategory.CHEMIST]: 'Chemist',
[JobCategory.CONTRACTOR]: 'Contractor',
[JobCategory.AUDITOR_ACCOUNTANT]: 'Auditor/Accountant',
[JobCategory.RESEARCHER]: 'Researcher',
[JobCategory.ACCOUNTANT]: 'Accountant',
[JobCategory.JOURNALIST]: 'Journalist',
[JobCategory.DESIGNER]: 'Designer',
[JobCategory.COMPUTER_SPECIALIST]: 'Computer Specialist',
[JobCategory.TRANSLATOR]: 'Translator',
[JobCategory.TEACHER]: 'Teacher',
[JobCategory.PILOT]: 'Pilot',
[JobCategory.HOST]: 'Host',
[JobCategory.OFFICER]: 'Officer',
[JobCategory.SOLDIER]: 'Soldier',
[JobCategory.RETIRED]: 'Retired',
[JobCategory.SALESMAN]: 'Salesman',
[JobCategory.AUTHOR]: 'Author',
[JobCategory.CRAFTSMAN]: 'Craftsman',
[JobCategory.SECURITY]: 'Security',
[JobCategory.LABORER]: 'Laborer',
[JobCategory.DRIVER]: 'Driver',
[JobCategory.FARMER]: 'Farmer',
[JobCategory.HOUSEWIFE]: 'Housewife',
[JobCategory.DIPLOMAT]: 'Diplomat',
[JobCategory.STUDENT]: 'Student',
[JobCategory.FREELANCER]: 'Freelancer',
[JobCategory.SHEPHERD]: 'Shepherd',
[JobCategory.HOUSEMAID_OR_BABYSITTER]: 'Housemaid/Babysitter',
[JobCategory.CAPTAIN]: 'Captain',
[JobCategory.AMBASSADOR]: 'Ambassador',
[JobCategory.MARKETING]: 'Marketing',
[JobCategory.CONSULTING]: 'Consulting',
[JobCategory.SUPERVISOR]: 'Supervisor',
[JobCategory.BANKER]: 'Banker',
[JobCategory.BODYGUARD_OR_PERSONAL_ASSISTANT]: 'Bodyguard/Personal Assistant',
}),
incomeRanges: this.enumToOptions(IncomeRange, {
[IncomeRange.BELOW_2000]: 'SAR 2,000 and below',
[IncomeRange.RANGE_2000_5000]: 'SAR 2,000 to 5,000',
[IncomeRange.RANGE_5000_10000]: 'SAR 5,000 to 10,000',
[IncomeRange.RANGE_10000_20000]: 'SAR 10,000 to 20,000',
[IncomeRange.ABOVE_20000]: 'SAR 20,000 and above',
}),
};
}
private enumToOptions(enumObj: any, labels: Record<string, string>): MetadataOptionDto[] {
return Object.keys(enumObj).map((key) => ({
value: enumObj[key],
label: labels[enumObj[key]] || enumObj[key],
}));
}
}

View File

@ -0,0 +1,65 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
import { PoiType } from '../enums';
@ValidatorConstraint({ name: 'IsValidPoiNumber', async: false })
export class IsValidPoiNumberConstraint implements ValidatorConstraintInterface {
validate(poiNumber: string, args: ValidationArguments) {
const object = args.object as any;
const poiType = object.poiType;
if (!poiNumber || !poiType) {
return false;
}
// Saudi National ID: 10 digits, typically starts with 1 or 2
const nationalIdPattern = /^[12]\d{9}$/;
// Iqama (Resident ID): 10 digits, typically starts with other numbers (not 1 or 2)
const iqamaPattern = /^[3-9]\d{9}$/;
if (poiType === PoiType.NAT) {
return nationalIdPattern.test(poiNumber);
}
if (poiType === PoiType.IQA) {
return iqamaPattern.test(poiNumber);
}
return false;
}
defaultMessage(args: ValidationArguments) {
const object = args.object as any;
const poiType = object.poiType;
if (poiType === PoiType.NAT) {
return 'National ID must be 10 digits and start with 1 or 2';
}
if (poiType === PoiType.IQA) {
return 'Iqama number must be 10 digits and start with 3-9';
}
return 'Invalid POI number format';
}
}
export function IsValidPoiNumber(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsValidPoiNumberConstraint,
});
};
}

View File

@ -19,6 +19,10 @@
"TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية." "TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية."
}, },
"QR": {
"CODE_USED_OR_EXPIRED": "تم استخدام رمز QR مسبقًا أو انتهت صلاحيته."
},
"USER": { "USER": {
"PHONE_ALREADY_VERIFIED": "تم التحقق من رقم الهاتف بالفعل.", "PHONE_ALREADY_VERIFIED": "تم التحقق من رقم الهاتف بالفعل.",
"EMAIL_ALREADY_VERIFIED": "تم التحقق من عنوان البريد الإلكتروني بالفعل.", "EMAIL_ALREADY_VERIFIED": "تم التحقق من عنوان البريد الإلكتروني بالفعل.",

View File

@ -19,6 +19,10 @@
"TOKEN_EXPIRED": "The user token has expired." "TOKEN_EXPIRED": "The user token has expired."
}, },
"QR": {
"CODE_USED_OR_EXPIRED": "The QR code has already been used or expired."
},
"USER": { "USER": {
"PHONE_ALREADY_VERIFIED": "The phone number has already been verified.", "PHONE_ALREADY_VERIFIED": "The phone number has already been verified.",
"EMAIL_ALREADY_VERIFIED": "The email address has already been verified.", "EMAIL_ALREADY_VERIFIED": "The email address has already been verified.",

View File

@ -5,6 +5,7 @@ import { Roles } from '~/auth/enums';
import { CardService, TransactionService } from '~/card/services'; import { CardService, TransactionService } from '~/card/services';
import { NeoLeapService } from '~/common/modules/neoleap/services'; import { NeoLeapService } from '~/common/modules/neoleap/services';
import { PageOptionsRequestDto } from '~/core/dtos'; import { PageOptionsRequestDto } from '~/core/dtos';
import { ErrorCategory } from '~/core/enums';
import { setIf } from '~/core/utils'; import { setIf } from '~/core/utils';
import { CustomerService } from '~/customer/services'; import { CustomerService } from '~/customer/services';
import { DocumentService, OciService } from '~/document/services'; import { DocumentService, OciService } from '~/document/services';
@ -113,7 +114,28 @@ export class JuniorService {
} }
junior.customer.user.email = body.email; junior.customer.user.email = body.email;
} }
setIf(user, 'profilePictureId', body.profilePictureId); // Update profile picture: ensure FK and relation are consistent to avoid TypeORM overriding the FK
if (typeof body.profilePictureId !== 'undefined') {
if (body.profilePictureId) {
const document = await this.documentService.findDocumentById(body.profilePictureId);
if (!document) {
this.logger.error(`Document with id ${body.profilePictureId} not found`);
throw new BadRequestException('DOCUMENT.NOT_FOUND');
}
if (document.createdById !== juniorId) {
this.logger.error(
`Document with id ${body.profilePictureId} does not belong to user ${juniorId}`,
);
}
user.profilePictureId = body.profilePictureId;
// assign relation to keep it consistent with FK during save
user.profilePicture = document as any;
} else {
// if empty string provided (unlikely), clear relation and FK
user.profilePicture = null as any;
user.profilePictureId = null as any;
}
}
setIf(user, 'firstName', body.firstName); setIf(user, 'firstName', body.firstName);
setIf(user, 'lastName', body.lastName); setIf(user, 'lastName', body.lastName);
@ -125,7 +147,7 @@ export class JuniorService {
setIf(junior, 'relationship', body.relationship); setIf(junior, 'relationship', body.relationship);
await Promise.all([junior.save(), customer.save(), user.save()]); await Promise.all([junior.save(), customer.save(), user.save()]);
this.logger.log(`Junior ${juniorId} updated successfully`); this.logger.log(`Junior ${juniorId} updated successfully`);
return junior; return this.findJuniorById(juniorId, false, guardianId);
} }
@Transactional() @Transactional()
@ -158,7 +180,14 @@ export class JuniorService {
async validateToken(token: string) { async validateToken(token: string) {
this.logger.log(`Validating token ${token}`); this.logger.log(`Validating token ${token}`);
const juniorId = await this.userTokenService.validateToken(token, UserType.JUNIOR); const juniorId = await this.userTokenService.validateToken(token, UserType.JUNIOR);
return this.findJuniorById(juniorId!, true); const junior = await this.findJuniorById(juniorId!, true);
if (junior.customer?.user?.password) {
this.logger.error(`Token ${token} already used for junior ${juniorId}`);
throw new BadRequestException({ message: 'QR.CODE_USED_OR_EXPIRED', category: ErrorCategory.BUSINESS_ERROR });
}
return junior;
} }
async generateToken(juniorId: string) { async generateToken(juniorId: string) {