refactor: handle kyc journey for customers

This commit is contained in:
Abdalhamid Alhamad
2025-02-20 16:18:06 +03:00
parent 270753cfd7
commit dae9cb6323
74 changed files with 1116 additions and 477 deletions

View File

@ -23,7 +23,8 @@
"migration:generate": "npm run typeorm:cli-d migration:generate", "migration:generate": "npm run typeorm:cli-d migration:generate",
"migration:create": "npm run typeorm:cli migration:create", "migration:create": "npm run typeorm:cli migration:create",
"migration:up": "npm run typeorm:cli-d migration:run", "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": { "dependencies": {
"@abdalhamid/hello": "^2.0.0", "@abdalhamid/hello": "^2.0.0",

View File

@ -8,7 +8,7 @@ import { AuthService, Oauth2Service } from './services';
import { AccessTokenStrategy } from './strategies'; import { AccessTokenStrategy } from './strategies';
@Module({ @Module({
imports: [JwtModule.register({}), JuniorModule, UserModule, HttpModule], imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule],
providers: [AuthService, AccessTokenStrategy, Oauth2Service], providers: [AuthService, AccessTokenStrategy, Oauth2Service],
controllers: [AuthController], controllers: [AuthController],
exports: [], exports: [],

View File

@ -18,11 +18,11 @@ export class LoginResponseDto {
user!: UserResponseDto; user!: UserResponseDto;
@ApiProperty({ example: CustomerResponseDto }) @ApiProperty({ example: CustomerResponseDto })
customer!: CustomerResponseDto; customer!: CustomerResponseDto | null;
constructor(IVerifyUserResponse: ILoginResponse, user: User) { constructor(IVerifyUserResponse: ILoginResponse, user: User) {
this.user = new UserResponseDto(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.accessToken = IVerifyUserResponse.accessToken;
this.refreshToken = IVerifyUserResponse.refreshToken; this.refreshToken = IVerifyUserResponse.refreshToken;
this.expiresAt = IVerifyUserResponse.expiresAt; this.expiresAt = IVerifyUserResponse.expiresAt;

View File

@ -21,6 +21,15 @@ export class UserResponseDto {
@ApiProperty() @ApiProperty()
isProfileCompleted!: boolean; isProfileCompleted!: boolean;
@ApiProperty()
isSmsEnabled!: boolean;
@ApiProperty()
isEmailEnabled!: boolean;
@ApiProperty()
isPushEnabled!: boolean;
@ApiProperty() @ApiProperty()
roles!: Roles[]; roles!: Roles[];
@ -31,6 +40,9 @@ export class UserResponseDto {
this.countryCode = user.countryCode; this.countryCode = user.countryCode;
this.isPasswordSet = user.isPasswordSet; this.isPasswordSet = user.isPasswordSet;
this.isProfileCompleted = user.isProfileCompleted; this.isProfileCompleted = user.isProfileCompleted;
this.isSmsEnabled = user.isSmsEnabled;
this.isEmailEnabled = user.isEmailEnabled;
this.isPushEnabled = user.isPushEnabled;
this.roles = user.roles; this.roles = user.roles;
} }
} }

View File

@ -1,4 +1,6 @@
export enum Roles { export enum Roles {
JUNIOR = 'JUNIOR', JUNIOR = 'JUNIOR',
GUARDIAN = 'GUARDIAN', GUARDIAN = 'GUARDIAN',
CHECKER = 'CHECKER',
SUPER_ADMIN = 'SUPER_ADMIN',
} }

View File

@ -7,8 +7,8 @@ import { ArrayContains } from 'typeorm';
import { CacheService } from '~/common/modules/cache/services'; import { CacheService } from '~/common/modules/cache/services';
import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services'; import { OtpService } from '~/common/modules/otp/services';
import { JuniorTokenService } from '~/junior/services'; import { UserType } from '~/user/enums';
import { DeviceService, UserService } from '~/user/services'; import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities'; import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants'; import { PASSCODE_REGEX } from '../constants';
import { import {
@ -39,7 +39,7 @@ export class AuthService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly deviceService: DeviceService, private readonly deviceService: DeviceService,
private readonly juniorTokenService: JuniorTokenService, private readonly userTokenService: UserTokenService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly oauth2Service: Oauth2Service, private readonly oauth2Service: Oauth2Service,
) {} ) {}
@ -91,13 +91,15 @@ export class AuthService {
return [tokens, user]; 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( this.logger.log(
`User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} verified successfully`, `User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} verified successfully`,
); );
return [tokens, updatedUser]; return [tokens, user];
} }
async setEmail(userId: string, { email }: SetEmailRequestDto) { async setEmail(userId: string, { email }: SetEmailRequestDto) {
@ -293,11 +295,11 @@ export class AuthService {
async setJuniorPasscode(body: setJuniorPasswordRequestDto) { async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`); 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 salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(body.passcode, salt); const hashedPasscode = bcrypt.hashSync(body.passcode, salt);
await this.userService.setPasscode(juniorId, hashedPasscode, salt); await this.userService.setPasscode(juniorId!, hashedPasscode, salt);
await this.juniorTokenService.invalidateToken(body.qrToken); await this.userTokenService.invalidateToken(body.qrToken);
this.logger.log(`Passcode set successfully for junior with id ${juniorId}`); this.logger.log(`Passcode set successfully for junior with id ${juniorId}`);
} }

View File

@ -3,4 +3,5 @@ export enum NotificationScope {
TASK_COMPLETED = 'TASK_COMPLETED', TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED', GIFT_RECEIVED = 'GIFT_RECEIVED',
OTP = 'OTP', OTP = 'OTP',
USER_INVITED = 'USER_INVITED',
} }

View File

@ -1,5 +1,5 @@
import { MailerModule } from '@nestjs-modules/mailer'; import { MailerModule } from '@nestjs-modules/mailer';
import { Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { TwilioModule } from 'nestjs-twilio'; import { TwilioModule } from 'nestjs-twilio';
@ -21,7 +21,7 @@ import { FirebaseService, NotificationsService, TwilioService } from './services
useFactory: buildMailerOptions, useFactory: buildMailerOptions,
inject: [ConfigService], inject: [ConfigService],
}), }),
UserModule, forwardRef(() => UserModule),
], ],
providers: [NotificationsService, FirebaseService, NotificationsRepository, TwilioService], providers: [NotificationsService, FirebaseService, NotificationsRepository, TwilioService],
exports: [NotificationsService], exports: [NotificationsService],

View File

@ -25,36 +25,6 @@ export class NotificationsService {
private readonly mailerService: MailerService, 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) { async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
this.logger.log(`Getting notifications for user ${userId}`); this.logger.log(`Getting notifications for user ${userId}`);
const [[notifications, count], unreadCount] = await Promise.all([ const [[notifications, count], unreadCount] = await Promise.all([
@ -77,6 +47,18 @@ export class NotificationsService {
return this.notificationRepository.markAsRead(userId); 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) { async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) {
this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`); this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`);
const notification = await this.createNotification({ const notification = await this.createNotification({
@ -92,10 +74,42 @@ export class NotificationsService {
return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification); 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) { private getTemplateFromNotification(notification: Notification) {
switch (notification.scope) { switch (notification.scope) {
case NotificationScope.OTP: case NotificationScope.OTP:
return 'otp'; return 'otp';
case NotificationScope.USER_INVITED:
return 'user-invite';
default: default:
return 'otp'; return 'otp';
} }
@ -115,8 +129,8 @@ export class NotificationsService {
return this.sendEmail({ return this.sendEmail({
to: notification.recipient!, to: notification.recipient!,
subject: notification.title, subject: notification.title,
data,
template: this.getTemplateFromNotification(notification), template: this.getTemplateFromNotification(notification),
data,
}); });
} }
} }

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're Invited!</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
.header {
font-size: 24px;
font-weight: bold;
color: #333;
}
.content {
font-size: 16px;
color: #555;
margin: 20px 0;
}
.btn {
display: inline-block;
background-color: #007bff;
color: #ffffff;
text-decoration: none;
padding: 12px 20px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
}
.footer {
font-size: 14px;
color: #888;
margin-top: 20px;
}
.link {
color: #007bff;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">You're Invited to Join Us!</div>
<p class="content">
You've been invited to join our platform. Click the button below to accept the invitation and set up your account.
</p>
<a href="{{inviteLink}}" class="btn">Accept Invitation</a>
<p class="content">
If the button above doesn't work, you can copy and paste this link into your browser:<br>
<a href="{{inviteLink}}" class="link">{{inviteLink}}</a>
</p>
<div class="footer">
If you didnt request this invitation, please ignore this email.
</div>
</div>
</body>
</html>

View File

@ -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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { DEVICE_ID_HEADER } from '~/common/constants';
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 { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; import { CreateCustomerRequestDto, UpdateCustomerRequestDto } from '../dtos/request';
import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response'; import { CustomerResponseDto } from '../dtos/response';
import { CustomerService } from '../services'; import { CustomerService } from '../services';
@Controller('customers') @Controller('customers')
@ -26,7 +25,7 @@ export class CustomerController {
return ResponseFactory.data(new CustomerResponseDto(customer)); return ResponseFactory.data(new CustomerResponseDto(customer));
} }
@Patch('') @Patch()
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
@ApiDataResponse(CustomerResponseDto) @ApiDataResponse(CustomerResponseDto)
async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) { async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) {
@ -35,16 +34,12 @@ export class CustomerController {
return ResponseFactory.data(new CustomerResponseDto(customer)); return ResponseFactory.data(new CustomerResponseDto(customer));
} }
@Patch('settings/notifications') @Post('')
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
@ApiDataResponse(NotificationSettingsResponseDto) @ApiDataResponse(CustomerResponseDto)
async updateNotificationSettings( async createCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateCustomerRequestDto) {
@AuthenticatedUser() { sub }: IJwtPayload, const customer = await this.customerService.createGuardianCustomer(sub, body);
@Body() body: UpdateNotificationsSettingsRequestDto,
@Headers(DEVICE_ID_HEADER) deviceId: string,
) {
const notificationSettings = await this.customerService.updateNotificationSettings(sub, body, deviceId);
return ResponseFactory.data(new NotificationSettingsResponseDto(notificationSettings)); return ResponseFactory.data(new CustomerResponseDto(customer));
} }
} }

View File

@ -1 +1,2 @@
export * from './customer.controller'; export * from './customer.controller';
export * from './internal.customer.controller';

View File

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

View File

@ -1,15 +1,15 @@
import { forwardRef, Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { GuardianModule } from '~/guardian/guardian.module';
import { UserModule } from '~/user/user.module'; import { UserModule } from '~/user/user.module';
import { CustomerController } from './controllers'; import { CustomerController, InternalCustomerController } from './controllers';
import { Customer } from './entities'; import { Customer } from './entities';
import { CustomerNotificationSettings } from './entities/customer-notification-settings.entity';
import { CustomerRepository } from './repositories/customer.repository'; import { CustomerRepository } from './repositories/customer.repository';
import { CustomerService } from './services'; import { CustomerService } from './services';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Customer, CustomerNotificationSettings]), forwardRef(() => UserModule)], imports: [TypeOrmModule.forFeature([Customer]), forwardRef(() => UserModule), GuardianModule],
controllers: [CustomerController], controllers: [CustomerController, InternalCustomerController],
providers: [CustomerService, CustomerRepository], providers: [CustomerService, CustomerRepository],
exports: [CustomerService], exports: [CustomerService],
}) })

View File

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

View File

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

View File

@ -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-customer.request.dto';
export * from './update-notifications-settings.request.dto';

View File

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

View File

@ -1,32 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsDateString, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsOptional, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { IsAbove18 } from '~/core/decorators/validations'; import { CreateCustomerRequestDto } from './create-customer.request.dto';
export class UpdateCustomerRequestDto { export class UpdateCustomerRequestDto extends PartialType(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: '123e4567-e89b-12d3-a456-426614174000' }) @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'customer.profilePictureId' }) }) @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'customer.profilePictureId' }) })
@IsOptional() @IsOptional()

View File

@ -1,17 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { CustomerStatus, KycStatus } from '~/customer/enums';
import { DocumentMetaResponseDto } from '~/document/dtos/response'; import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { NotificationSettingsResponseDto } from './notification-settings.response.dto';
export class CustomerResponseDto { export class CustomerResponseDto {
@ApiProperty() @ApiProperty()
id!: string; id!: string;
@ApiProperty() @ApiProperty()
customerStatus!: string; customerStatus!: CustomerStatus;
@ApiProperty() @ApiProperty()
rejectionReason!: string; kycStatus!: KycStatus;
@ApiProperty()
rejectionReason!: string | null;
@ApiProperty() @ApiProperty()
firstName!: string; firstName!: string;
@ -52,15 +55,13 @@ export class CustomerResponseDto {
@ApiProperty() @ApiProperty()
isGuardian!: boolean; isGuardian!: boolean;
@ApiProperty()
notificationSettings!: NotificationSettingsResponseDto;
@ApiPropertyOptional({ type: DocumentMetaResponseDto }) @ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null; profilePicture!: DocumentMetaResponseDto | null;
constructor(customer: Customer) { constructor(customer: Customer) {
this.id = customer.id; this.id = customer.id;
this.customerStatus = customer.customerStatus; this.customerStatus = customer.customerStatus;
this.kycStatus = customer.kycStatus;
this.rejectionReason = customer.rejectionReason; this.rejectionReason = customer.rejectionReason;
this.firstName = customer.firstName; this.firstName = customer.firstName;
this.lastName = customer.lastName; this.lastName = customer.lastName;
@ -75,7 +76,7 @@ export class CustomerResponseDto {
this.gender = customer.gender; this.gender = customer.gender;
this.isJunior = customer.isJunior; this.isJunior = customer.isJunior;
this.isGuardian = customer.isGuardian; this.isGuardian = customer.isGuardian;
this.notificationSettings = new NotificationSettingsResponseDto(customer.notificationSettings);
this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null; this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null;
} }
} }

View File

@ -1,2 +1 @@
export * from './customer-response.dto'; export * from './customer-response.dto';
export * from './notification-settings.response.dto';

View File

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

View File

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

View File

@ -12,18 +12,21 @@ import { Document } from '~/document/entities';
import { Guardian } from '~/guardian/entities/guradian.entity'; import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities'; import { Junior } from '~/junior/entities';
import { User } from '~/user/entities'; import { User } from '~/user/entities';
import { CustomerNotificationSettings } from './customer-notification-settings.entity'; import { CustomerStatus, KycStatus } from '../enums';
@Entity('customers') @Entity('customers')
export class Customer extends BaseEntity { export class Customer extends BaseEntity {
@PrimaryColumn('uuid') @PrimaryColumn('uuid')
id!: string; id!: string;
@Column('varchar', { length: 255, default: 'PENDING', name: 'customer_status' }) @Column('varchar', { length: 255, default: CustomerStatus.PENDING, name: 'customer_status' })
customerStatus!: string; customerStatus!: CustomerStatus;
@Column('varchar', { length: 255, default: KycStatus.PENDING, name: 'kyc_status' })
kycStatus!: KycStatus;
@Column('text', { nullable: true, name: 'rejection_reason' }) @Column('text', { nullable: true, name: 'rejection_reason' })
rejectionReason!: string; rejectionReason!: string | null;
@Column('varchar', { length: 255, nullable: true, name: 'first_name' }) @Column('varchar', { length: 255, nullable: true, name: 'first_name' })
firstName!: string; firstName!: string;
@ -70,12 +73,6 @@ export class Customer extends BaseEntity {
@Column('varchar', { name: 'profile_picture_id', nullable: true }) @Column('varchar', { name: 'profile_picture_id', nullable: true })
profilePictureId!: string; profilePictureId!: string;
@OneToOne(() => CustomerNotificationSettings, (notificationSettings) => notificationSettings.customer, {
cascade: true,
eager: true,
})
notificationSettings!: CustomerNotificationSettings;
@OneToOne(() => Document, (document) => document.customerPicture, { cascade: true, nullable: true }) @OneToOne(() => Document, (document) => document.customerPicture, { cascade: true, nullable: true })
@JoinColumn({ name: 'profile_picture_id' }) @JoinColumn({ name: 'profile_picture_id' })
profilePicture!: Document; profilePicture!: Document;
@ -90,6 +87,20 @@ export class Customer extends BaseEntity {
@OneToOne(() => Guardian, (guardian) => guardian.customer, { cascade: true }) @OneToOne(() => Guardian, (guardian) => guardian.customer, { cascade: true })
guardian!: Guardian; 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' }) @CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date; createdAt!: Date;

View File

@ -0,0 +1,5 @@
export enum CustomerStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}

View File

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

View File

@ -0,0 +1,5 @@
export enum KycStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}

View File

@ -1,12 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm'; import { FindOptionsWhere, Repository } from 'typeorm';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { CreateJuniorRequestDto } from '~/junior/dtos/request'; import { CreateJuniorRequestDto } from '~/junior/dtos/request';
import { Junior } from '~/junior/entities'; import { CreateCustomerRequestDto, CustomerFiltersRequestDto } from '../dtos/request';
import { UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { Customer } from '../entities'; import { Customer } from '../entities';
import { CustomerNotificationSettings } from '../entities/customer-notification-settings.entity';
@Injectable() @Injectable()
export class CustomerRepository { export class CustomerRepository {
@ -20,44 +17,52 @@ export class CustomerRepository {
return this.customerRepository.findOne({ where, relations: ['profilePicture'] }); 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( return this.customerRepository.save(
this.customerRepository.create({ this.customerRepository.create({
id: userId,
userId,
isGuardian,
isJunior: !isGuardian,
firstName: body.firstName, firstName: body.firstName,
lastName: body.lastName, lastName: body.lastName,
dateOfBirth: body.dateOfBirth, dateOfBirth: body.dateOfBirth,
junior: Junior.create({
id: userId,
guardianId,
relationship: body.relationship,
civilIdFrontId: body.civilIdFrontId, civilIdFrontId: body.civilIdFrontId,
civilIdBackId: body.civilIdBackId, civilIdBackId: body.civilIdBackId,
}), }),
id: userId,
userId,
isJunior: true,
notificationSettings: new CustomerNotificationSettings(),
}),
); );
} }
createGuardianCustomer(userId: string) { findCustomers(filters: CustomerFiltersRequestDto) {
return this.customerRepository.save( const query = this.customerRepository.createQueryBuilder('customer');
this.customerRepository.create({
notificationSettings: new CustomerNotificationSettings(),
guardian: Guardian.create({ id: userId }),
id: userId,
userId,
isGuardian: true,
}),
);
}
updateNotificationSettings(customer: Customer, body: UpdateNotificationsSettingsRequestDto) { if (filters.name) {
customer.notificationSettings = CustomerNotificationSettings.create({ const nameParts = filters.name.trim().split(/\s+/);
...customer.notificationSettings, console.log(nameParts);
...body, 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();
}
findCustomerByCivilId(civilIdFrontId: string, civilIdBackId: string) {
return this.customerRepository.findOne({
where: [{ civilIdFrontId, civilIdBackId }, { civilIdFrontId }, { civilIdBackId }],
}); });
return this.customerRepository.save(customer);
} }
} }

View File

@ -1,9 +1,16 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Transactional } from 'typeorm-transactional';
import { DocumentService, OciService } from '~/document/services'; import { DocumentService, OciService } from '~/document/services';
import { GuardianService } from '~/guardian/services';
import { CreateJuniorRequestDto } from '~/junior/dtos/request'; import { CreateJuniorRequestDto } from '~/junior/dtos/request';
import { DeviceService } from '~/user/services'; import {
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; CreateCustomerRequestDto,
CustomerFiltersRequestDto,
RejectCustomerKycRequestDto,
UpdateCustomerRequestDto,
} from '../dtos/request';
import { Customer } from '../entities'; import { Customer } from '../entities';
import { KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository'; import { CustomerRepository } from '../repositories/customer.repository';
@Injectable() @Injectable()
@ -12,26 +19,9 @@ export class CustomerService {
constructor( constructor(
private readonly customerRepository: CustomerRepository, private readonly customerRepository: CustomerRepository,
private readonly ociService: OciService, private readonly ociService: OciService,
private readonly deviceService: DeviceService,
private readonly documentService: DocumentService, 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<Customer> { async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise<Customer> {
this.logger.log(`Updating customer ${userId}`); this.logger.log(`Updating customer ${userId}`);
@ -42,14 +32,12 @@ export class CustomerService {
return this.findCustomerById(userId); return this.findCustomerById(userId);
} }
createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) { async createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) {
this.logger.log(`Creating customer for user ${juniorId}`); this.logger.log(`Creating junior customer for user ${juniorId}`);
return this.customerRepository.createJuniorCustomer(guardianId, juniorId, body);
}
createGuardianCustomer(userId: string) { await this.validateCivilIdForCustomer(guardianId, body.civilIdFrontId, body.civilIdBackId);
this.logger.log(`Creating guardian customer for user ${userId}`);
return this.customerRepository.createGuardianCustomer(userId); return this.customerRepository.createCustomer(juniorId, body, false);
} }
async findCustomerById(id: string) { async findCustomerById(id: string) {
@ -69,6 +57,61 @@ export class CustomerService {
return customer; 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) { private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) {
if (!profilePictureId) return; if (!profilePictureId) return;
@ -86,4 +129,37 @@ export class CustomerService {
throw new BadRequestException('DOCUMENT.NOT_CREATED_BY_USER'); 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');
}
}
} }

View File

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddKycStatusToCustomer1739868002943 implements MigrationInterface {
name = 'AddKycStatusToCustomer1739868002943';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "customers" ADD "kyc_status" character varying(255) NOT NULL DEFAULT 'PENDING'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "kyc_status"`);
}
}

View File

@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCivilidToCustomersAndUpdateNotificationsSettings1739954239949 implements MigrationInterface {
name = 'AddCivilidToCustomersAndUpdateNotificationsSettings1739954239949'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`);
}
}

View File

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateUserRegistrationTable1740045960580 implements MigrationInterface {
name = 'CreateUserRegistrationTable1740045960580'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -19,3 +19,6 @@ export * from './1734861516657-create-gift-entities';
export * from './1734944692999-create-notification-entity-and-edit-device'; export * from './1734944692999-create-notification-entity-and-edit-device';
export * from './1736414850257-add-flags-to-user-entity'; export * from './1736414850257-add-flags-to-user-entity';
export * from './1736753223884-add_created_by_to_document_table'; 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';

View File

@ -10,7 +10,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { Gift } from '~/gift/entities'; import { Gift } from '~/gift/entities';
import { Junior, Theme } from '~/junior/entities'; import { Theme } from '~/junior/entities';
import { SavingGoal } from '~/saving-goals/entities'; import { SavingGoal } from '~/saving-goals/entities';
import { Task } from '~/task/entities'; import { Task } from '~/task/entities';
import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { TaskSubmission } from '~/task/entities/task-submissions.entity';
@ -37,11 +37,11 @@ export class Document {
@OneToOne(() => Customer, (customer) => customer.profilePicture, { onDelete: 'SET NULL' }) @OneToOne(() => Customer, (customer) => customer.profilePicture, { onDelete: 'SET NULL' })
customerPicture?: Customer; customerPicture?: Customer;
@OneToOne(() => Junior, (junior) => junior.civilIdFront, { onDelete: 'SET NULL' }) @OneToOne(() => Customer, (customer) => customer.civilIdFront, { onDelete: 'SET NULL' })
juniorCivilIdFront?: User; customerCivilIdFront?: User;
@OneToOne(() => Junior, (junior) => junior.civilIdBack, { onDelete: 'SET NULL' }) @OneToOne(() => Customer, (customer) => customer.civilIdBack, { onDelete: 'SET NULL' })
juniorCivilIdBack?: User; customerCivilIdBack?: User;
@OneToMany(() => Theme, (theme) => theme.avatar) @OneToMany(() => Theme, (theme) => theme.avatar)
themes?: Theme[]; themes?: Theme[];

View File

@ -1,8 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Guardian } from './entities/guradian.entity'; import { Guardian } from './entities/guradian.entity';
import { GuardianRepository } from './repositories';
import { GuardianService } from './services';
@Module({ @Module({
providers: [GuardianService, GuardianRepository],
imports: [TypeOrmModule.forFeature([Guardian])], imports: [TypeOrmModule.forFeature([Guardian])],
exports: [GuardianService],
}) })
export class GuardianModule {} export class GuardianModule {}

View File

@ -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<Guardian>) {}
createGuardian(customerId: string) {
return this.guardianRepository.save(
this.guardianRepository.create({
id: customerId,
customerId,
}),
);
}
}

View File

@ -0,0 +1 @@
export * from './guardian.repository';

View File

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

View File

@ -0,0 +1 @@
export * from './guardian.service';

View File

@ -24,6 +24,7 @@
}, },
"customer": { "customer": {
"name": "الاسم",
"firstName": "الاسم الأول", "firstName": "الاسم الأول",
"lastName": "اسم العائلة", "lastName": "اسم العائلة",
"countryOfResidence": "بلد الإقامة", "countryOfResidence": "بلد الإقامة",
@ -31,7 +32,8 @@
"profilePictureId": "معرّف صورة الملف الشخصي", "profilePictureId": "معرّف صورة الملف الشخصي",
"isEmailEnabled": "هل البريد الإلكتروني مفعّل", "isEmailEnabled": "هل البريد الإلكتروني مفعّل",
"isSmsEnabled": "هل الرسائل النصية مفعّلة", "isSmsEnabled": "هل الرسائل النصية مفعّلة",
"isPushEnabled": "هل الإشعارات مفعّلة" "isPushEnabled": "هل الإشعارات مفعّلة",
"kycStatus": "حالة التحقق من الهوية"
}, },
"junior": { "junior": {

View File

@ -25,6 +25,7 @@
}, },
"customer": { "customer": {
"name": "Name",
"firstName": "First name", "firstName": "First name",
"lastName": "Last name", "lastName": "Last name",
"countryOfResidence": "Country of residence", "countryOfResidence": "Country of residence",
@ -32,7 +33,8 @@
"profilePictureId": "Profile picture ID", "profilePictureId": "Profile picture ID",
"isEmailEnabled": "Is Email enabled", "isEmailEnabled": "Is Email enabled",
"isSmsEnabled": "Is SMS enabled", "isSmsEnabled": "Is SMS enabled",
"isPushEnabled": "Is Push enabled" "isPushEnabled": "Is Push enabled",
"kycStatus": "KYC status"
}, },
"junior": { "junior": {

View File

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

View File

@ -1,9 +1,40 @@
import { ApiProperty } from '@nestjs/swagger'; 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 { 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 { Relationship } from '~/junior/enums';
import { CreateJuniorUserRequestDto } from './create-junior-user.request.dto'; export class CreateJuniorRequestDto {
export class CreateJuniorRequestDto extends 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;
@ApiProperty({ example: Relationship.PARENT }) @ApiProperty({ example: Relationship.PARENT })
@IsEnum(Relationship, { message: i18n('validation.IsEnum', { path: 'general', property: 'junior.relationship' }) }) @IsEnum(Relationship, { message: i18n('validation.IsEnum', { path: 'general', property: 'junior.relationship' }) })
relationship!: Relationship; relationship!: Relationship;

View File

@ -1,3 +1,2 @@
export * from './create-junior-user.request.dto';
export * from './create-junior.request.dto'; export * from './create-junior.request.dto';
export * from './set-theme.request.dto'; export * from './set-theme.request.dto';

View File

@ -1,3 +1,2 @@
export * from './junior-registration-token.entity';
export * from './junior.entity'; export * from './junior.entity';
export * from './theme.entity'; export * from './theme.entity';

View File

@ -12,14 +12,13 @@ import {
} from 'typeorm'; } from 'typeorm';
import { Allowance } from '~/allowance/entities'; import { Allowance } from '~/allowance/entities';
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { Document } from '~/document/entities';
import { Gift } from '~/gift/entities'; import { Gift } from '~/gift/entities';
import { Guardian } from '~/guardian/entities/guradian.entity'; import { Guardian } from '~/guardian/entities/guradian.entity';
import { MoneyRequest } from '~/money-request/entities'; import { MoneyRequest } from '~/money-request/entities';
import { Category, SavingGoal } from '~/saving-goals/entities'; import { Category, SavingGoal } from '~/saving-goals/entities';
import { Task } from '~/task/entities'; import { Task } from '~/task/entities';
import { UserRegistrationToken } from '~/user/entities';
import { Relationship } from '../enums'; import { Relationship } from '../enums';
import { JuniorRegistrationToken } from './junior-registration-token.entity';
import { Theme } from './theme.entity'; import { Theme } from './theme.entity';
@Entity('juniors') @Entity('juniors')
@ -30,26 +29,12 @@ export class Junior extends BaseEntity {
@Column('varchar', { length: 255 }) @Column('varchar', { length: 255 })
relationship!: Relationship; 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' }) @Column('uuid', { name: 'customer_id' })
customerId!: string; customerId!: string;
@Column('uuid', { name: 'guardian_id' }) @Column('uuid', { name: 'guardian_id' })
guardianId!: string; 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' }) @OneToOne(() => Customer, (customer) => customer.junior, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'customer_id' }) @JoinColumn({ name: 'customer_id' })
customer!: Customer; customer!: Customer;
@ -70,8 +55,8 @@ export class Junior extends BaseEntity {
@OneToMany(() => Category, (category) => category.junior) @OneToMany(() => Category, (category) => category.junior)
categories!: Category[]; categories!: Category[];
@OneToMany(() => JuniorRegistrationToken, (token) => token.junior) @OneToMany(() => UserRegistrationToken, (token) => token.junior)
registrationTokens!: JuniorRegistrationToken[]; registrationTokens!: UserRegistrationToken[];
@OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.requester) @OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.requester)
moneyRequests!: MoneyRequest[]; moneyRequests!: MoneyRequest[];

View File

@ -4,21 +4,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomerModule } from '~/customer/customer.module'; import { CustomerModule } from '~/customer/customer.module';
import { UserModule } from '~/user/user.module'; import { UserModule } from '~/user/user.module';
import { JuniorController } from './controllers'; import { JuniorController } from './controllers';
import { Junior, JuniorRegistrationToken, Theme } from './entities'; import { Junior, Theme } from './entities';
import { JuniorRepository, JuniorTokenRepository } from './repositories'; import { JuniorRepository } from './repositories';
import { BranchIoService, JuniorService, JuniorTokenService, QrcodeService } from './services'; import { BranchIoService, JuniorService, QrcodeService } from './services';
@Module({ @Module({
controllers: [JuniorController], controllers: [JuniorController],
providers: [ providers: [JuniorService, JuniorRepository, QrcodeService, BranchIoService],
JuniorService, imports: [TypeOrmModule.forFeature([Junior, Theme]), UserModule, CustomerModule, HttpModule],
JuniorRepository, exports: [JuniorService],
JuniorTokenService,
JuniorTokenRepository,
QrcodeService,
BranchIoService,
],
imports: [TypeOrmModule.forFeature([Junior, Theme, JuniorRegistrationToken]), UserModule, CustomerModule, HttpModule],
exports: [JuniorService, JuniorTokenService],
}) })
export class JuniorModule {} export class JuniorModule {}

View File

@ -1,2 +1 @@
export * from './junior-token.repository';
export * from './junior.repository'; export * from './junior.repository';

View File

@ -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<JuniorRegistrationToken>,
) {}
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 });
}
}

View File

@ -43,9 +43,12 @@ export class JuniorRepository {
return this.juniorRepository.manager.findOne(Theme, { where: { juniorId }, relations: ['avatar'] }); return this.juniorRepository.manager.findOne(Theme, { where: { juniorId }, relations: ['avatar'] });
} }
findJuniorByCivilId(civilIdFrontId: string, civilIdBackId: string) { createJunior(userId: string, data: Partial<Junior>) {
return this.juniorRepository.findOne({ return this.juniorRepository.save(
where: [{ civilIdFrontId, civilIdBackId }, { civilIdFrontId }, { civilIdBackId }], this.juniorRepository.create({
}); id: userId,
...data,
}),
);
} }
} }

View File

@ -1,4 +1,3 @@
export * from './branch-io.service'; export * from './branch-io.service';
export * from './junior-token.service';
export * from './junior.service'; export * from './junior.service';
export * from './qrcode.service'; export * from './qrcode.service';

View File

@ -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<string> {
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);
}
}

View File

@ -4,36 +4,40 @@ import { Roles } from '~/auth/enums';
import { PageOptionsRequestDto } from '~/core/dtos'; import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomerService } from '~/customer/services'; import { CustomerService } from '~/customer/services';
import { DocumentService, OciService } from '~/document/services'; import { DocumentService, OciService } from '~/document/services';
import { UserType } from '~/user/enums';
import { UserService } from '~/user/services'; import { UserService } from '~/user/services';
import { UserTokenService } from '~/user/services/user-token.service';
import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request'; import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request';
import { Junior } from '../entities'; import { Junior } from '../entities';
import { JuniorRepository } from '../repositories'; import { JuniorRepository } from '../repositories';
import { JuniorTokenService } from './junior-token.service'; import { QrcodeService } from './qrcode.service';
@Injectable() @Injectable()
export class JuniorService { export class JuniorService {
private readonly logger = new Logger(JuniorService.name); private readonly logger = new Logger(JuniorService.name);
constructor( constructor(
private readonly juniorRepository: JuniorRepository, private readonly juniorRepository: JuniorRepository,
private readonly juniorTokenService: JuniorTokenService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly userTokenService: UserTokenService,
private readonly customerService: CustomerService, private readonly customerService: CustomerService,
private readonly documentService: DocumentService, private readonly documentService: DocumentService,
private readonly ociService: OciService, private readonly ociService: OciService,
private readonly qrCodeService: QrcodeService,
) {} ) {}
@Transactional() @Transactional()
async createJuniors(body: CreateJuniorRequestDto, guardianId: string) { async createJuniors(body: CreateJuniorRequestDto, guardianId: string) {
this.logger.log(`Creating junior for guardian ${guardianId}`); 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) { if (existingUser) {
this.logger.error(`User with email ${body.email} or phone number ${body.phoneNumber} already exists`); this.logger.error(`User with email ${body.email} or phone number ${body.phoneNumber} already exists`);
throw new BadRequestException('USER.ALREADY_EXISTS'); throw new BadRequestException('USER.ALREADY_EXISTS');
} }
await this.validateJuniorDocuments(guardianId, body.civilIdFrontId, body.civilIdBackId);
const user = await this.userService.createUser({ const user = await this.userService.createUser({
email: body.email, email: body.email,
countryCode: body.countryCode, countryCode: body.countryCode,
@ -41,11 +45,17 @@ export class JuniorService {
roles: [Roles.JUNIOR], 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`); 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) { async findJuniorById(juniorId: string, withGuardianRelation = false, guardianId?: string) {
@ -90,13 +100,16 @@ 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.juniorTokenService.validateToken(token); const juniorId = await this.userTokenService.validateToken(token, UserType.JUNIOR);
return this.findJuniorById(juniorId, true); return this.findJuniorById(juniorId!, true);
} }
generateToken(juniorId: string) { async generateToken(juniorId: string) {
this.logger.log(`Generating token for junior ${juniorId}`); 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) { async doesJuniorBelongToGuardian(guardianId: string, juniorId: string) {
@ -106,38 +119,6 @@ export class JuniorService {
return !!junior; 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[]) { private async prepareJuniorImages(juniors: Junior[]) {
this.logger.log(`Preparing junior images`); this.logger.log(`Preparing junior images`);
await Promise.all( await Promise.all(

49
src/scripts/seed.ts Normal file
View File

@ -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<Repository<User>>('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();

View File

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

View File

@ -0,0 +1,2 @@
export * from './admin.user.controller';
export * from './user.controller';

View File

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

View File

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

View File

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

View File

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

View File

@ -3,22 +3,22 @@ import { IsBoolean, IsOptional, IsString, ValidateIf } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class UpdateNotificationsSettingsRequestDto { export class UpdateNotificationsSettingsRequestDto {
@ApiProperty() @ApiProperty()
@IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isEmailEnabled' }) }) @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isEmailEnabled' }) })
@IsOptional() @IsOptional()
isEmailEnabled!: boolean; isEmailEnabled!: boolean;
@ApiProperty() @ApiProperty()
@IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isPushEnabled' }) }) @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isPushEnabled' }) })
@IsOptional() @IsOptional()
isPushEnabled!: boolean; isPushEnabled!: boolean;
@ApiProperty() @ApiProperty()
@IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isSmsEnabled' }) }) @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'customer.isSmsEnabled' }) })
@IsOptional() @IsOptional()
isSmsEnabled!: boolean; isSmsEnabled!: boolean;
@ApiPropertyOptional() @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) @ValidateIf((o) => o.isPushEnabled)
fcmToken?: string; fcmToken?: string;
} }

View File

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

View File

@ -1,2 +1,3 @@
export * from './device.entity'; export * from './device.entity';
export * from './user-registration-token.entity';
export * from './user.entity'; export * from './user.entity';

View File

@ -9,10 +9,12 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } 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') @Entity('user_registration_tokens')
export class JuniorRegistrationToken extends BaseEntity { export class UserRegistrationToken extends BaseEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@ -20,16 +22,26 @@ export class JuniorRegistrationToken extends BaseEntity {
@Index() @Index()
token!: string; token!: string;
@Column({ type: 'varchar', length: 255, name: 'user_type' })
userType!: UserType;
@Column({ type: 'boolean', default: false, name: 'is_used' }) @Column({ type: 'boolean', default: false, name: 'is_used' })
isUsed!: boolean; isUsed!: boolean;
@Column({ type: 'timestamp', name: 'expiry_date' }) @Column({ type: 'timestamp', name: 'expiry_date' })
expiryDate!: Date; expiryDate!: Date;
@Column({ type: 'uuid', name: 'junior_id' }) @Column({ type: 'uuid', name: 'user_id', nullable: true })
juniorId!: string; 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' }) @JoinColumn({ name: 'junior_id' })
junior!: Junior; junior!: Junior;

View File

@ -14,6 +14,7 @@ import { Customer } from '~/customer/entities/customer.entity';
import { Document } from '~/document/entities'; import { Document } from '~/document/entities';
import { Roles } from '../../auth/enums'; import { Roles } from '../../auth/enums';
import { Device } from './device.entity'; import { Device } from './device.entity';
import { UserRegistrationToken } from './user-registration-token.entity';
@Entity('users') @Entity('users')
export class User extends BaseEntity { export class User extends BaseEntity {
@ -50,6 +51,15 @@ export class User extends BaseEntity {
@Column('boolean', { default: false, name: 'is_profile_completed' }) @Column('boolean', { default: false, name: 'is_profile_completed' })
isProfileCompleted!: boolean; 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' }) @Column('text', { nullable: true, array: true, name: 'roles' })
roles!: Roles[]; roles!: Roles[];
@ -68,6 +78,9 @@ export class User extends BaseEntity {
@OneToMany(() => Document, (document) => document.createdBy) @OneToMany(() => Document, (document) => document.createdBy)
createdDocuments!: Document[]; createdDocuments!: Document[];
@OneToMany(() => UserRegistrationToken, (token) => token.user)
registrationTokens!: UserRegistrationToken[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date; createdAt!: Date;

1
src/user/enums/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './user-type.enum';

View File

@ -0,0 +1,4 @@
export enum UserType {
CHECKER = 'CHECKER',
JUNIOR = 'JUNIOR',
}

View File

@ -1,2 +1,3 @@
export * from './device.repository'; export * from './device.repository';
export * from './user-token-repository';
export * from './user.repository'; export * from './user.repository';

View File

@ -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<UserRegistrationToken>,
) {}
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 });
}
}

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm'; import { FindOptionsWhere, Repository } from 'typeorm';
import { User } from '../../user/entities'; import { User } from '../../user/entities';
import { UserFiltersRequestDto } from '../dtos/request';
@Injectable() @Injectable()
export class UserRepository { export class UserRepository {
@ -32,4 +33,24 @@ export class UserRepository {
return this.userRepository.save(user); 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();
}
} }

View File

@ -1,2 +1,3 @@
export * from './device.service'; export * from './device.service';
export * from './user-token.service';
export * from './user.service'; export * from './user.service';

View File

@ -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<string> {
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<string> {
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);
}
}

View File

@ -1,18 +1,34 @@
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; 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 { FindOptionsWhere } from 'typeorm';
import { Transactional } from 'typeorm-transactional'; import { Transactional } from 'typeorm-transactional';
import { CustomerService } from '~/customer/services'; import { NotificationsService } from '~/common/modules/notification/services';
import { CreateUnverifiedUserRequestDto } from '../../auth/dtos/request'; import { CreateUnverifiedUserRequestDto } from '../../auth/dtos/request';
import { Roles } from '../../auth/enums'; import { Roles } from '../../auth/enums';
import {
CreateCheckerRequestDto,
SetInternalPasswordRequestDto,
UpdateNotificationsSettingsRequestDto,
UserFiltersRequestDto,
} from '../dtos/request';
import { User } from '../entities'; import { User } from '../entities';
import { UserType } from '../enums';
import { UserRepository } from '../repositories'; import { UserRepository } from '../repositories';
import { DeviceService } from './device.service';
import { UserTokenService } from './user-token.service';
const SALT_ROUNDS = 10;
@Injectable() @Injectable()
export class UserService { export class UserService {
private readonly logger = new Logger(UserService.name); private readonly logger = new Logger(UserService.name);
private adminPortalUrl = this.configService.getOrThrow('ADMIN_PORTAL_URL');
constructor( constructor(
private readonly userRepository: UserRepository, 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<User> | FindOptionsWhere<User>[]) { findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
@ -20,6 +36,31 @@ export class UserService {
return this.userRepository.findOne(where); 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<User>) { async findUserOrThrow(where: FindOptionsWhere<User>) {
this.logger.log(`Finding user with where clause ${JSON.stringify(where)}`); this.logger.log(`Finding user with where clause ${JSON.stringify(where)}`);
const user = await this.findUser(where); const user = await this.findUser(where);
@ -64,27 +105,50 @@ export class UserService {
return user; 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() @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) { async createGoogleUser(googleId: string, email: string) {
this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`); this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`);
const user = await this.userRepository.createUser({ const user = await this.userRepository.createUser({
@ -94,12 +158,9 @@ export class UserService {
isEmailVerified: true, isEmailVerified: true,
}); });
await this.customerService.createGuardianCustomer(user.id);
return this.findUserOrThrow({ id: user.id }); return this.findUserOrThrow({ id: user.id });
} }
@Transactional()
async createAppleUser(appleId: string, email: string) { async createAppleUser(appleId: string, email: string) {
this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`); this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`);
const user = await this.userRepository.createUser({ const user = await this.userRepository.createUser({
@ -109,18 +170,24 @@ export class UserService {
isEmailVerified: true, isEmailVerified: true,
}); });
await this.customerService.createGuardianCustomer(user.id);
return this.findUserOrThrow({ id: user.id }); return this.findUserOrThrow({ id: user.id });
} }
@Transactional() async setCheckerPassword(data: SetInternalPasswordRequestDto) {
async verifyUserAndCreateCustomer(userId: string) { const userId = await this.userTokenService.validateToken(data.token, UserType.CHECKER);
this.logger.log(`Verifying user ${userId} and creating customer`); this.logger.log(`Setting password for checker ${userId}`);
await this.userRepository.update(userId, { isPhoneVerified: true }); const salt = bcrypt.genSaltSync(SALT_ROUNDS);
await this.customerService.createGuardianCustomer(userId); const hashedPasscode = bcrypt.hashSync(data.password, salt);
this.logger.log(`User ${userId} verified and customer created successfully`); return this.userRepository.update(userId, { password: hashedPasscode, salt, isProfileCompleted: true });
return this.findUserOrThrow({ id: userId }); }
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}` },
});
} }
} }

View File

@ -1,13 +1,15 @@
import { forwardRef, Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomerModule } from '~/customer/customer.module'; import { NotificationModule } from '~/common/modules/notification/notification.module';
import { Device, User } from './entities'; import { AdminUserController, UserController } from './controllers';
import { DeviceRepository, UserRepository } from './repositories'; import { Device, User, UserRegistrationToken } from './entities';
import { DeviceService, UserService } from './services'; import { DeviceRepository, UserRepository, UserTokenRepository } from './repositories';
import { DeviceService, UserService, UserTokenService } from './services';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, Device]), forwardRef(() => CustomerModule)], imports: [TypeOrmModule.forFeature([User, Device, UserRegistrationToken]), forwardRef(() => NotificationModule)],
providers: [UserService, DeviceService, UserRepository, DeviceRepository], providers: [UserService, DeviceService, UserRepository, DeviceRepository, UserTokenRepository, UserTokenService],
exports: [UserService, DeviceService], exports: [UserService, DeviceService, UserTokenService],
controllers: [UserController, AdminUserController],
}) })
export class UserModule {} export class UserModule {}