feat: working on edit profile ticket

This commit is contained in:
Abdalhamid Alhamad
2025-08-05 17:53:38 +03:00
parent 1e2b859b92
commit 275984954e
37 changed files with 298 additions and 275 deletions

View File

@ -25,7 +25,13 @@ export class AllowanceChangeRequestsRepository {
findAllowanceChangeRequestBy(where: FindOptionsWhere<AllowanceChangeRequest>, withRelations = false) {
const relations = withRelations
? ['allowance', 'allowance.junior', 'allowance.junior.customer', 'allowance.junior.customer.profilePicture']
? [
'allowance',
'allowance.junior',
'allowance.junior.customer',
'allowance.junior.customer.user',
'allowance.junior.customer.user.profilePicture',
]
: [];
return this.allowanceChangeRequestsRepository.findOne({ where, relations });
}
@ -43,7 +49,8 @@ export class AllowanceChangeRequestsRepository {
'allowance',
'allowance.junior',
'allowance.junior.customer',
'allowance.junior.customer.profilePicture',
'allowance.junior.customer.user',
'allowance.junior.customer.user.profilePicture',
],
});
}

View File

@ -28,14 +28,14 @@ export class AllowancesRepository {
findAllowanceById(allowanceId: string, guardianId?: string) {
return this.allowancesRepository.findOne({
where: { id: allowanceId, guardianId },
relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'],
relations: ['junior', 'junior.customer', 'junior.customer.user', 'junior.customer.user.profilePicture'],
});
}
findAllowances(guardianId: string, query: PageOptionsRequestDto) {
return this.allowancesRepository.findAndCount({
where: { guardianId },
relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'],
relations: ['junior', 'junior.customer', 'junior.customer.user', 'junior.customer.user.profilePicture'],
take: query.size,
skip: query.size * (query.page - ONE),
});

View File

@ -122,7 +122,7 @@ export class AllowanceChangeRequestsService {
this.logger.log(`Preparing allowance change requests images`);
return Promise.all(
requests.map(async (request) => {
const profilePicture = request.allowance.junior.customer.profilePicture;
const profilePicture = request.allowance.junior.customer.user.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}

View File

@ -100,7 +100,7 @@ export class AllowancesService {
this.logger.log(`Preparing document for allowances`);
await Promise.all(
allowance.map(async (allowance) => {
const profilePicture = allowance.junior.customer.profilePicture;
const profilePicture = allowance.junior.customer.user.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}

View File

@ -39,7 +39,7 @@ export class VerifyUserRequestDto {
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
@ApiProperty({ example: '2021-01-01' })
@ApiProperty({ example: '2001-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) })
dateOfBirth!: Date;

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { User } from '~/user/entities';
export class UserResponseDto {
@ -7,42 +7,39 @@ export class UserResponseDto {
id!: string;
@ApiProperty()
email!: string;
countryCode!: string;
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
countryCode!: string;
email!: string;
@ApiProperty()
isPasswordSet!: boolean;
firstName!: string;
@ApiProperty()
isProfileCompleted!: boolean;
lastName!: string;
@ApiPropertyOptional({ type: DocumentMetaResponseDto, nullable: true })
profilePicture!: DocumentMetaResponseDto | null;
@ApiProperty()
isSmsEnabled!: boolean;
isPhoneVerified!: boolean;
@ApiProperty()
isEmailEnabled!: boolean;
@ApiProperty()
isPushEnabled!: boolean;
@ApiProperty()
roles!: Roles[];
isEmailVerified!: boolean;
constructor(user: User) {
this.id = user.id;
this.email = user.email;
this.phoneNumber = user.phoneNumber;
this.countryCode = user.countryCode;
this.isPasswordSet = user.isPasswordSet;
this.isProfileCompleted = user.isProfileCompleted;
this.isSmsEnabled = user.isSmsEnabled;
this.isEmailEnabled = user.isEmailEnabled;
this.isPushEnabled = user.isPushEnabled;
this.roles = user.roles;
this.phoneNumber = user.phoneNumber;
this.email = user.email;
this.firstName = user.firstName;
this.lastName = user.lastName;
this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null;
this.isEmailVerified = user.isEmailVerified;
this.isPhoneVerified = user.isPhoneVerified;
}
}

View File

@ -20,13 +20,13 @@ export class CreateCustomerRequestDto {
@IsOptional()
gender?: Gender;
@ApiProperty({ example: 'JO' })
@ApiProperty({ enum: CountryIso, example: CountryIso.SAUDI_ARABIA })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
})
countryOfResidence!: CountryIso;
@ApiProperty({ example: '2021-01-01' })
@ApiProperty({ example: '2001-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) })
dateOfBirth!: Date;

View File

@ -104,7 +104,5 @@ export class CustomerResponseDto {
this.neighborhood = customer.neighborhood;
this.street = customer.street;
this.building = customer.building;
this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null;
}
}

View File

@ -84,6 +84,5 @@ export class InternalCustomerDetailsResponseDto {
this.isGuardian = customer.isGuardian;
this.civilIdFront = new DocumentMetaResponseDto(customer.civilIdFront);
this.civilIdBack = new DocumentMetaResponseDto(customer.civilIdBack);
this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null;
}
}

View File

@ -96,13 +96,6 @@ export class Customer extends BaseEntity {
@Column('varchar', { name: 'building', length: 255, nullable: true })
building!: string;
@Column('varchar', { name: 'profile_picture_id', nullable: true })
profilePictureId!: string;
@OneToOne(() => Document, (document) => document.customerPicture, { cascade: true, nullable: true })
@JoinColumn({ name: 'profile_picture_id' })
profilePicture!: Document;
@OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;

View File

@ -15,7 +15,7 @@ export class CustomerRepository {
findOne(where: FindOptionsWhere<Customer>) {
return this.customerRepository.findOne({
where,
relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'],
relations: ['user', 'civilIdFront', 'civilIdBack', 'cards'],
});
}
@ -36,7 +36,6 @@ export class CustomerRepository {
findCustomers(filters: CustomerFiltersRequestDto) {
const query = this.customerRepository.createQueryBuilder('customer');
query.leftJoinAndSelect('customer.profilePicture', 'profilePicture');
query.leftJoinAndSelect('customer.user', 'user');
if (filters.name) {

View File

@ -30,6 +30,9 @@ export class CustomerService {
this.logger.log(`Updating customer ${userId}`);
await this.validateProfilePictureForCustomer(userId, data.profilePictureId);
if (data.civilIdBackId || data.civilIdFrontId) {
await this.validateCivilIdForCustomer(userId, data.civilIdFrontId!, data.civilIdBackId!);
}
await this.customerRepository.updateCustomer(userId, data);
this.logger.log(`Customer ${userId} updated successfully`);
return this.findCustomerById(userId);
@ -52,9 +55,6 @@ export class CustomerService {
throw new BadRequestException('CUSTOMER.NOT_FOUND');
}
if (customer.profilePicture) {
customer.profilePicture.url = await this.ociService.generatePreSignedUrl(customer.profilePicture);
}
this.logger.log(`Customer ${id} found successfully`);
return customer;
}
@ -101,8 +101,6 @@ export class CustomerService {
throw new BadRequestException('CUSTOMER.ALREADY_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}`);
@ -215,14 +213,7 @@ export class CustomerService {
promises.push(this.ociService.generatePreSignedUrl(customer.civilIdFront));
promises.push(this.ociService.generatePreSignedUrl(customer.civilIdBack));
if (customer.profilePicture) {
promises.push(this.ociService.generatePreSignedUrl(customer.profilePicture));
}
const [civilIdFrontUrl, civilIdBackUrl, profilePictureUrl] = await Promise.all(promises);
if (customer.profilePicture) {
customer.profilePicture.url = profilePictureUrl;
}
customer.civilIdFront.url = civilIdFrontUrl;
customer.civilIdBack.url = civilIdBackUrl;

View File

@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDisplayNameToUser1754399872619 implements MigrationInterface {
name = 'AddDisplayNameToUser1754399872619';
public async up(queryRunner: QueryRunner): Promise<void> {
// Step 1: Add columns as nullable
await queryRunner.query(`ALTER TABLE "users" ADD "first_name" character varying(255)`);
await queryRunner.query(`ALTER TABLE "users" ADD "last_name" character varying(255)`);
// Step 2: Populate the new columns with fallback to test values
await queryRunner.query(`
UPDATE "users"
SET "first_name" = COALESCE(c."first_name", 'TEST_FIRST_NAME'),
"last_name" = COALESCE(c."last_name", 'TEST_LAST_NAME')
FROM "customers" c
WHERE c.user_id = "users"."id"
`);
// Step 2b: Handle users without a matching customer row
await queryRunner.query(`
UPDATE "users"
SET "first_name" = COALESCE("first_name", 'TEST_FIRST_NAME'),
"last_name" = COALESCE("last_name", 'TEST_LAST_NAME')
WHERE "first_name" IS NULL OR "last_name" IS NULL
`);
// Step 3: Make the columns NOT NULL
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "first_name" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "last_name" SET NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "last_name"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "first_name"`);
}
}

View File

@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProfilePictureToUserInsteadOfCustomer1754401348483 implements MigrationInterface {
name = 'AddProfilePictureToUserInsteadOfCustomer1754401348483';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_e7574892da11dd01de5cfc46499"`);
await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "REL_e7574892da11dd01de5cfc4649"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "profile_picture_id"`);
await queryRunner.query(`ALTER TABLE "users" ADD "profile_picture_id" uuid`);
await queryRunner.query(
`ALTER TABLE "users" ADD CONSTRAINT "UQ_02ec15de199e79a0c46869895f4" UNIQUE ("profile_picture_id")`,
);
await queryRunner.query(
`ALTER TABLE "users" ADD CONSTRAINT "FK_02ec15de199e79a0c46869895f4" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_02ec15de199e79a0c46869895f4"`);
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_02ec15de199e79a0c46869895f4"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "profile_picture_id"`);
await queryRunner.query(`ALTER TABLE "customers" ADD "profile_picture_id" uuid`);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "REL_e7574892da11dd01de5cfc4649" UNIQUE ("profile_picture_id")`,
);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_e7574892da11dd01de5cfc46499" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
}

View File

@ -6,3 +6,5 @@ export * from './1753874205042-add-neoleap-related-entities';
export * from './1753948642040-add-account-number-and-iban-to-account-entity';
export * from './1754210729273-add-vpan-to-card';
export * from './1754226754947-add-upload-status-to-document-entity';
export * from './1754399872619-add-display-name-to-user';
export * from './1754401348483-add-profile-picture-to-user-instead-of-customer';

View File

@ -37,8 +37,8 @@ export class Document {
@Column({ type: 'uuid', nullable: true, name: 'created_by_id' })
createdById!: string;
@OneToOne(() => Customer, (customer) => customer.profilePicture, { onDelete: 'SET NULL' })
customerPicture?: Customer;
@OneToOne(() => User, (user) => user.profilePicture, { onDelete: 'SET NULL' })
userPicture?: Customer;
@OneToOne(() => Customer, (customer) => customer.civilIdFront, { onDelete: 'SET NULL' })
customerCivilIdFront?: User;

View File

@ -51,7 +51,7 @@ export class OciService {
}
const bucketName = BUCKETS[document.documentType];
const objectName = document.name;
const objectName = document.name + document.extension;
const expiration = moment().add(TWO, 'hours').toDate();
try {

View File

@ -20,8 +20,8 @@ export class JuniorResponseDto {
this.id = junior.id;
this.fullName = `${junior.customer.firstName} ${junior.customer.lastName}`;
this.relationship = junior.relationship;
this.profilePicture = junior.customer.profilePicture
? new DocumentMetaResponseDto(junior.customer.profilePicture)
this.profilePicture = junior.customer.user.profilePicture
? new DocumentMetaResponseDto(junior.customer.user.profilePicture)
: null;
}
}

View File

@ -13,7 +13,7 @@ export class JuniorRepository {
findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) {
return this.juniorRepository.findAndCount({
where: { guardianId },
relations: ['customer', 'customer.user', 'customer.profilePicture'],
relations: ['customer', 'customer.user', 'customer.user.profilePicture'],
skip: (pageOptions.page - FIRST_PAGE) * pageOptions.size,
take: pageOptions.size,
});

View File

@ -132,7 +132,7 @@ export class JuniorService {
this.logger.log(`Preparing junior images`);
await Promise.all(
juniors.map(async (junior) => {
const profilePicture = junior.customer.profilePicture;
const profilePicture = junior.customer.user.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);

View File

@ -34,7 +34,8 @@ export class MoneyRequestsRepository {
const query = this.moneyRequestRepository.createQueryBuilder('moneyRequest');
query.leftJoinAndSelect('moneyRequest.requester', 'requester');
query.leftJoinAndSelect('requester.customer', 'customer');
query.leftJoinAndSelect('customer.profilePicture', 'profilePicture');
query.leftJoinAndSelect('customer.user', 'user');
query.leftJoinAndSelect('user.profilePicture', 'profilePicture');
query.orderBy('moneyRequest.createdAt', 'DESC');
query.where('moneyRequest.reviewerId = :reviewerId', { reviewerId });
query.andWhere('moneyRequest.status = :status', { status: filters.status });

View File

@ -106,7 +106,7 @@ export class MoneyRequestsService {
this.logger.log(`Preparing document for money requests`);
await Promise.all(
moneyRequests.map(async (moneyRequest) => {
const profilePicture = moneyRequest.requester.customer.profilePicture;
const profilePicture = moneyRequest.requester.customer.user.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);

View File

@ -36,7 +36,8 @@ export class TaskRepository {
'image',
'assignedTo',
'assignedTo.customer',
'assignedTo.customer.profilePicture',
'assignedTo.customer.user',
'assignedTo.customer.user.profilePicture',
'submission',
'submission.proofOfCompletion',
],
@ -50,7 +51,8 @@ export class TaskRepository {
.leftJoinAndSelect('task.image', 'image')
.leftJoinAndSelect('task.assignedTo', 'assignedTo')
.leftJoinAndSelect('assignedTo.customer', 'customer')
.leftJoinAndSelect('customer.profilePicture', 'profilePicture')
.leftJoinAndSelect('customer.user', 'user')
.leftJoinAndSelect('user.profilePicture', 'profilePicture')
.leftJoinAndSelect('task.submission', 'submission')
.leftJoinAndSelect('submission.proofOfCompletion', 'proofOfCompletion');

View File

@ -132,7 +132,7 @@ export class TaskService {
const [imageUrl, submissionUrl, profilePictureUrl] = await Promise.all([
this.ociService.generatePreSignedUrl(task.image),
this.ociService.generatePreSignedUrl(task.submission?.proofOfCompletion),
this.ociService.generatePreSignedUrl(task.assignedTo.customer.profilePicture),
this.ociService.generatePreSignedUrl(task.assignedTo.customer.user.profilePicture),
]);
task.image.url = imageUrl;
@ -141,8 +141,8 @@ export class TaskService {
task.submission.proofOfCompletion.url = submissionUrl;
}
if (task.assignedTo.customer.profilePicture) {
task.assignedTo.customer.profilePicture.url = profilePictureUrl;
if (task.assignedTo.customer.user.profilePicture) {
task.assignedTo.customer.user.profilePicture.url = profilePictureUrl;
}
}),
);

View File

@ -1,50 +0,0 @@
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

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

View File

@ -1,20 +1,52 @@
import { Body, Controller, Headers, HttpCode, HttpStatus, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Headers, HttpCode, HttpStatus, Patch, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { VerifyOtpRequestDto } from '~/auth/dtos/request';
import { UserResponseDto } from '~/auth/dtos/response';
import { IJwtPayload } from '~/auth/interfaces';
import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser, Public } from '~/common/decorators';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { SetInternalPasswordRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { UpdateNotificationsSettingsRequestDto, UpdateUserRequestDto } from '../dtos/request';
import { UpdateEmailRequestDto } from '../dtos/request/update-email.request.dto';
import { UserService } from '../services';
@Controller('users')
@ApiTags('Users')
@Controller('profile')
@ApiTags('User - Profile')
@UseGuards(AccessTokenGuard)
@ApiBearerAuth()
export class UserController {
constructor(private userService: UserService) {}
@Get()
@HttpCode(HttpStatus.OK)
@ApiDataResponse(UserResponseDto)
async getProfile(@AuthenticatedUser() { sub }: IJwtPayload) {
const user = await this.userService.findUserOrThrow({ id: sub }, true);
return ResponseFactory.data(new UserResponseDto(user));
}
@Patch('')
@HttpCode(HttpStatus.NO_CONTENT)
async updateProfile(@AuthenticatedUser() user: IJwtPayload, @Body() data: UpdateUserRequestDto) {
return this.userService.updateUser(user.sub, data);
}
@Patch('email')
@HttpCode(HttpStatus.NO_CONTENT)
async updateEmail(@AuthenticatedUser() user: IJwtPayload, @Body() data: UpdateEmailRequestDto) {
return this.userService.updateUserEmail(user.sub, data.email);
}
@Patch('verify-email')
@HttpCode(HttpStatus.NO_CONTENT)
async verifyEmail(@AuthenticatedUser() user: IJwtPayload, @Body() { otp }: VerifyOtpRequestDto) {
return this.userService.verifyEmail(user.sub, otp);
}
@Patch('notifications-settings')
@HttpCode(HttpStatus.NO_CONTENT)
async updateNotificationSettings(
@AuthenticatedUser() user: IJwtPayload,
@Body() data: UpdateNotificationsSettingsRequestDto,
@ -22,11 +54,4 @@ export class UserController {
) {
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

@ -1,25 +0,0 @@
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

@ -1,4 +1,2 @@
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';
export * from './update-user.request.dto';

View File

@ -1,15 +0,0 @@
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

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsOptional } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class UpdateEmailRequestDto {
@ApiProperty({ example: 'test@test.com' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'user.email' }) })
@IsOptional()
email!: string;
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class UpdateUserRequestDto {
@ApiProperty({ example: 'John' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.firstName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'user.firstName' }) })
@IsOptional()
firstName!: string;
@ApiProperty({ example: 'Doe' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.lastName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'user.lastName' }) })
@IsOptional()
lastName!: string;
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'user.profilePictureId' }) })
@IsOptional()
profilePictureId!: string;
}

View File

@ -1,18 +0,0 @@
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

@ -3,6 +3,7 @@ import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
@ -21,7 +22,13 @@ export class User extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('varchar', { length: 255, nullable: true, name: 'email' })
@Column('varchar', { length: 255, name: 'first_name', nullable: false })
firstName!: string;
@Column('varchar', { length: 255, name: 'last_name', nullable: false })
lastName!: string;
@Column('varchar', { length: 255, name: 'email', nullable: true })
email!: string;
@Column('varchar', { length: 255, name: 'phone_number', nullable: true })
@ -81,6 +88,13 @@ export class User extends BaseEntity {
@OneToMany(() => UserRegistrationToken, (token) => token.user)
registrationTokens!: UserRegistrationToken[];
@Column('varchar', { name: 'profile_picture_id', nullable: true })
profilePictureId!: string;
@OneToOne(() => Document, (document) => document.userPicture, { cascade: true, nullable: true })
@JoinColumn({ name: 'profile_picture_id' })
profilePicture!: Document;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;

View File

@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { User } from '../../user/entities';
import { UserFiltersRequestDto } from '../dtos/request';
@Injectable()
export class UserRepository {
@ -17,7 +16,7 @@ export class UserRepository {
}
findOne(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
return this.userRepository.findOne({ where });
return this.userRepository.findOne({ where, relations: ['profilePicture'] });
}
update(userId: string, data: Partial<User>) {
@ -31,24 +30,4 @@ export class UserRepository {
return this.userRepository.save(user);
}
findUsers(filters: UserFiltersRequestDto) {
const queryBuilder = this.userRepository.createQueryBuilder('user');
if (filters.role) {
queryBuilder.andWhere(`user.roles @> ARRAY[:role]`, { role: filters.role });
}
if (filters.search) {
queryBuilder.andWhere(`user.email ILIKE :search OR user.phoneNumber ILIKE :search`, {
search: `%${filters.search}%`,
});
}
queryBuilder.orderBy('user.createdAt', 'DESC');
queryBuilder.take(filters.size);
queryBuilder.skip((filters.page - 1) * filters.size);
return queryBuilder.getManyAndCount();
}
}

View File

@ -1,25 +1,20 @@
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import moment from 'moment';
import { FindOptionsWhere } from 'typeorm';
import { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums';
import { NotificationsService } from '~/common/modules/notification/services';
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services';
import { CustomerService } from '~/customer/services';
import { DocumentService, OciService } from '~/document/services';
import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request';
import { Roles } from '../../auth/enums';
import {
CreateCheckerRequestDto,
SetInternalPasswordRequestDto,
UpdateNotificationsSettingsRequestDto,
UserFiltersRequestDto,
} from '../dtos/request';
import { UpdateNotificationsSettingsRequestDto, UpdateUserRequestDto } from '../dtos/request';
import { User } from '../entities';
import { UserType } from '../enums';
import { UserRepository } from '../repositories';
import { DeviceService } from './device.service';
import { UserTokenService } from './user-token.service';
const SALT_ROUNDS = 10;
@Injectable()
export class UserService {
@ -29,14 +24,21 @@ export class UserService {
private readonly userRepository: UserRepository,
@Inject(forwardRef(() => NotificationsService)) private readonly notificationsService: NotificationsService,
private readonly deviceService: DeviceService,
private readonly userTokenService: UserTokenService,
private readonly configService: ConfigService,
private customerService: CustomerService,
private readonly documentService: DocumentService,
private readonly otpService: OtpService,
private readonly ociService: OciService,
) {}
findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
async findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[], includeSignedUrl = false) {
this.logger.log(`finding user with where clause ${JSON.stringify(where)}`);
return this.userRepository.findOne(where);
const user = await this.userRepository.findOne(where);
if (user?.profilePicture && includeSignedUrl) {
user.profilePicture.url = await this.ociService.generatePreSignedUrl(user.profilePicture);
}
return user;
}
setEmail(userId: string, email: string) {
@ -75,14 +77,9 @@ export class UserService {
]);
}
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>, includeSignedUrl = false) {
this.logger.log(`Finding user with where clause ${JSON.stringify(where)}`);
const user = await this.findUser(where);
const user = await this.findUser(where, includeSignedUrl);
if (!user) {
this.logger.error(`User with not found with where clause ${JSON.stringify(where)}`);
@ -107,6 +104,8 @@ export class UserService {
phoneNumber: body.phoneNumber,
countryCode: body.countryCode,
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
roles: [Roles.GUARDIAN],
});
}
@ -134,29 +133,6 @@ export class UserService {
return user;
}
@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) {
@ -213,16 +189,9 @@ export class UserService {
return this.findUserOrThrow({ id: user.id });
}
async setCheckerPassword(data: SetInternalPasswordRequestDto) {
const userId = await this.userTokenService.validateToken(data.token, UserType.CHECKER);
this.logger.log(`Setting password for checker ${userId}`);
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(data.password, salt);
async updateUser(userId: string, data: UpdateUserRequestDto) {
await this.validateProfilePictureId(data.profilePictureId, userId);
return this.userRepository.update(userId, { password: hashedPasscode, salt, isProfileCompleted: true });
}
async updateUser(userId: string, data: Partial<User>) {
this.logger.log(`Updating user ${userId} with data ${JSON.stringify(data)}`);
const { affected } = await this.userRepository.update(userId, data);
if (affected === 0) {
@ -231,12 +200,72 @@ export class UserService {
}
}
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}` },
async updateUserEmail(userId: string, email: string) {
const userWithEmail = await this.findUser({ email, isEmailVerified: true });
if (userWithEmail) {
if (userWithEmail.id === userId) {
return;
}
this.logger.error(`Email ${email} is already taken by another user`);
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
}
this.logger.log(`Updating email for user ${userId} to ${email}`);
const { affected } = await this.userRepository.update(userId, { email, isEmailVerified: false });
if (affected === 0) {
this.logger.error(`User with id ${userId} not found`);
throw new BadRequestException('USER.NOT_FOUND');
}
return this.otpService.generateAndSendOtp({
userId,
recipient: email,
otpType: OtpType.EMAIL,
scope: OtpScope.VERIFY_EMAIL,
});
}
async verifyEmail(userId: string, otp: string) {
this.logger.log(`Verifying email for user ${userId} with otp ${otp}`);
const user = await this.findUserOrThrow({ id: userId });
if (user.isEmailVerified) {
this.logger.error(`User with id ${userId} already has verified email`);
throw new BadRequestException('USER.EMAIL_ALREADY_VERIFIED');
}
await this.otpService.verifyOtp({
userId,
value: otp,
scope: OtpScope.VERIFY_EMAIL,
otpType: OtpType.EMAIL,
});
await this.userRepository.update(userId, { isEmailVerified: true });
this.logger.log(`Email for user ${userId} verified successfully`);
}
private async validateProfilePictureId(profilePictureId: string, userId: string) {
if (!profilePictureId) {
return;
}
this.logger.log(`Validating profile picture id ${profilePictureId}`);
const document = await this.documentService.findDocumentById(profilePictureId);
if (!document) {
this.logger.error(`Document with id ${profilePictureId} not found`);
throw new BadRequestException('DOCUMENT.NOT_FOUND');
}
if (document.createdById !== userId) {
this.logger.error(`Document with id ${profilePictureId} does not belong to user ${userId}`);
throw new BadRequestException('DOCUMENT.NOT_BELONG_TO_USER');
}
this.logger.log(`Profile picture id ${profilePictureId} validated successfully`);
}
}

View File

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