diff --git a/src/allowance/repositories/allowance-change-request.repository.ts b/src/allowance/repositories/allowance-change-request.repository.ts index de2941f..f0eacf4 100644 --- a/src/allowance/repositories/allowance-change-request.repository.ts +++ b/src/allowance/repositories/allowance-change-request.repository.ts @@ -25,7 +25,13 @@ export class AllowanceChangeRequestsRepository { findAllowanceChangeRequestBy(where: FindOptionsWhere, 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', ], }); } diff --git a/src/allowance/repositories/allowances.repository.ts b/src/allowance/repositories/allowances.repository.ts index 2914ff1..a03b441 100644 --- a/src/allowance/repositories/allowances.repository.ts +++ b/src/allowance/repositories/allowances.repository.ts @@ -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), }); diff --git a/src/allowance/services/allowance-change-requests.service.ts b/src/allowance/services/allowance-change-requests.service.ts index 0e343ba..9e000fb 100644 --- a/src/allowance/services/allowance-change-requests.service.ts +++ b/src/allowance/services/allowance-change-requests.service.ts @@ -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); } diff --git a/src/allowance/services/allowances.service.ts b/src/allowance/services/allowances.service.ts index 9d3547a..1e35b33 100644 --- a/src/allowance/services/allowances.service.ts +++ b/src/allowance/services/allowances.service.ts @@ -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); } diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index f0d7289..94944a2 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -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; diff --git a/src/auth/dtos/response/user.response.dto.ts b/src/auth/dtos/response/user.response.dto.ts index 2d80e6f..f2a9853 100644 --- a/src/auth/dtos/response/user.response.dto.ts +++ b/src/auth/dtos/response/user.response.dto.ts @@ -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; } } diff --git a/src/customer/dtos/request/create-customer.request.dto.ts b/src/customer/dtos/request/create-customer.request.dto.ts index 83cc614..f6fc4b3 100644 --- a/src/customer/dtos/request/create-customer.request.dto.ts +++ b/src/customer/dtos/request/create-customer.request.dto.ts @@ -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; diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index f0b46f6..82bfe91 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -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; } } diff --git a/src/customer/dtos/response/internal.customer-details.response.dto.ts b/src/customer/dtos/response/internal.customer-details.response.dto.ts index ee8c985..62c56ed 100644 --- a/src/customer/dtos/response/internal.customer-details.response.dto.ts +++ b/src/customer/dtos/response/internal.customer-details.response.dto.ts @@ -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; } } diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index aa029ef..5bcc221 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -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; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index 85565a6..b506978 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -15,7 +15,7 @@ export class CustomerRepository { findOne(where: FindOptionsWhere) { 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) { diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 5690858..4b22d18 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -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; diff --git a/src/db/migrations/1754399872619-add-display-name-to-user.ts b/src/db/migrations/1754399872619-add-display-name-to-user.ts new file mode 100644 index 0000000..8612fbf --- /dev/null +++ b/src/db/migrations/1754399872619-add-display-name-to-user.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDisplayNameToUser1754399872619 implements MigrationInterface { + name = 'AddDisplayNameToUser1754399872619'; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "last_name"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "first_name"`); + } +} diff --git a/src/db/migrations/1754401348483-add-profile-picture-to-user-instead-of-customer.ts b/src/db/migrations/1754401348483-add-profile-picture-to-user-instead-of-customer.ts new file mode 100644 index 0000000..adc44bf --- /dev/null +++ b/src/db/migrations/1754401348483-add-profile-picture-to-user-instead-of-customer.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProfilePictureToUserInsteadOfCustomer1754401348483 implements MigrationInterface { + name = 'AddProfilePictureToUserInsteadOfCustomer1754401348483'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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`, + ); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 85c5217..9d9ed62 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -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'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 959f63d..b103790 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -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; diff --git a/src/document/services/oci.service.ts b/src/document/services/oci.service.ts index ad2f319..4749f3b 100644 --- a/src/document/services/oci.service.ts +++ b/src/document/services/oci.service.ts @@ -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 { diff --git a/src/junior/dtos/response/junior.response.dto.ts b/src/junior/dtos/response/junior.response.dto.ts index 5c293d6..b520190 100644 --- a/src/junior/dtos/response/junior.response.dto.ts +++ b/src/junior/dtos/response/junior.response.dto.ts @@ -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; } } diff --git a/src/junior/repositories/junior.repository.ts b/src/junior/repositories/junior.repository.ts index 316b3d4..cd1e3df 100644 --- a/src/junior/repositories/junior.repository.ts +++ b/src/junior/repositories/junior.repository.ts @@ -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, }); diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 5d2abbd..c8f3e95 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -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); diff --git a/src/money-request/repositories/money-requests.repository.ts b/src/money-request/repositories/money-requests.repository.ts index db93dea..f2a8761 100644 --- a/src/money-request/repositories/money-requests.repository.ts +++ b/src/money-request/repositories/money-requests.repository.ts @@ -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 }); diff --git a/src/money-request/services/money-requests.service.ts b/src/money-request/services/money-requests.service.ts index 7e9d7d9..760b594 100644 --- a/src/money-request/services/money-requests.service.ts +++ b/src/money-request/services/money-requests.service.ts @@ -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); diff --git a/src/task/repositories/task.repository.ts b/src/task/repositories/task.repository.ts index e53dc2b..3add476 100644 --- a/src/task/repositories/task.repository.ts +++ b/src/task/repositories/task.repository.ts @@ -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'); diff --git a/src/task/services/task.service.ts b/src/task/services/task.service.ts index 264c47d..974873c 100644 --- a/src/task/services/task.service.ts +++ b/src/task/services/task.service.ts @@ -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; } }), ); diff --git a/src/user/controllers/admin.user.controller.ts b/src/user/controllers/admin.user.controller.ts deleted file mode 100644 index 660a3eb..0000000 --- a/src/user/controllers/admin.user.controller.ts +++ /dev/null @@ -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)); - } -} diff --git a/src/user/controllers/index.ts b/src/user/controllers/index.ts index 4f30a96..edd3705 100644 --- a/src/user/controllers/index.ts +++ b/src/user/controllers/index.ts @@ -1,2 +1 @@ -export * from './admin.user.controller'; export * from './user.controller'; diff --git a/src/user/controllers/user.controller.ts b/src/user/controllers/user.controller.ts index 004776d..0b7005c 100644 --- a/src/user/controllers/user.controller.ts +++ b/src/user/controllers/user.controller.ts @@ -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); - } } diff --git a/src/user/dtos/request/create-checker.request.dto.ts b/src/user/dtos/request/create-checker.request.dto.ts deleted file mode 100644 index 1ce8ed1..0000000 --- a/src/user/dtos/request/create-checker.request.dto.ts +++ /dev/null @@ -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; -} diff --git a/src/user/dtos/request/index.ts b/src/user/dtos/request/index.ts index 30831e1..bc36355 100644 --- a/src/user/dtos/request/index.ts +++ b/src/user/dtos/request/index.ts @@ -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'; diff --git a/src/user/dtos/request/set-internal-password.request.dto.ts b/src/user/dtos/request/set-internal-password.request.dto.ts deleted file mode 100644 index ec49b65..0000000 --- a/src/user/dtos/request/set-internal-password.request.dto.ts +++ /dev/null @@ -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; -} diff --git a/src/user/dtos/request/update-email.request.dto.ts b/src/user/dtos/request/update-email.request.dto.ts new file mode 100644 index 0000000..0548b8e --- /dev/null +++ b/src/user/dtos/request/update-email.request.dto.ts @@ -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; +} diff --git a/src/user/dtos/request/update-user.request.dto.ts b/src/user/dtos/request/update-user.request.dto.ts new file mode 100644 index 0000000..5024952 --- /dev/null +++ b/src/user/dtos/request/update-user.request.dto.ts @@ -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; +} diff --git a/src/user/dtos/request/user-filters.request.dto.ts b/src/user/dtos/request/user-filters.request.dto.ts deleted file mode 100644 index 3f04cc5..0000000 --- a/src/user/dtos/request/user-filters.request.dto.ts +++ /dev/null @@ -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; -} diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index a18173d..ee3db70 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -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; diff --git a/src/user/repositories/user.repository.ts b/src/user/repositories/user.repository.ts index d740cd8..c55ff1d 100644 --- a/src/user/repositories/user.repository.ts +++ b/src/user/repositories/user.repository.ts @@ -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 | FindOptionsWhere[]) { - return this.userRepository.findOne({ where }); + return this.userRepository.findOne({ where, relations: ['profilePicture'] }); } update(userId: string, data: Partial) { @@ -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(); - } } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index 2640cbe..a9088ab 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -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 | FindOptionsWhere[]) { + async findUser(where: FindOptionsWhere | FindOptionsWhere[], 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) { + async findUserOrThrow(where: FindOptionsWhere, 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) { 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`); + } } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 50c740f..1ea07ae 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -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 {}