diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index d3df2ca..b8e6a9c 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { OciService } from '~/document/services'; +import { DocumentService, OciService } from '~/document/services'; import { User } from '~/user/entities'; import { DeviceService } from '~/user/services'; import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; @@ -13,6 +13,7 @@ export class CustomerService { private readonly customerRepository: CustomerRepository, private readonly ociService: OciService, private readonly deviceService: DeviceService, + private readonly documentService: DocumentService, ) {} async updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto, deviceId: string) { this.logger.log(`Updating notification settings for user ${userId}`); @@ -34,6 +35,8 @@ export class CustomerService { async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise { this.logger.log(`Updating customer ${userId}`); + + await this.validateProfilePictureForCustomer(userId, data.profilePictureId); await this.customerRepository.updateCustomer(userId, data); this.logger.log(`Customer ${userId} updated successfully`); return this.findCustomerById(userId); @@ -60,4 +63,20 @@ export class CustomerService { this.logger.log(`Customer ${id} found successfully`); return customer; } + + private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) { + if (!profilePictureId) return; + + this.logger.log(`Validating profile picture ${profilePictureId}`); + + const profilePicture = await this.documentService.findDocumentById(profilePictureId); + + if (!profilePicture) { + this.logger.error(`Profile picture ${profilePictureId} not found`); + throw new BadRequestException('DOCUMENT.NOT_FOUND'); + } + + if (profilePicture.createdById && profilePicture.createdById !== userId) { + } + } } diff --git a/src/db/migrations/1736753223884-add_created_by_to_document_table.ts b/src/db/migrations/1736753223884-add_created_by_to_document_table.ts new file mode 100644 index 0000000..1af1611 --- /dev/null +++ b/src/db/migrations/1736753223884-add_created_by_to_document_table.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCreatedByToDocumentTable1736753223884 implements MigrationInterface { + name = 'AddCreatedByToDocumentTable1736753223884'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "documents" ADD "created_by_id" uuid `); + await queryRunner.query( + `ALTER TABLE "documents" ADD CONSTRAINT "FK_7f46f4f77acde1dcedba64cb220" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "documents" DROP CONSTRAINT "FK_7f46f4f77acde1dcedba64cb220"`); + await queryRunner.query(`ALTER TABLE "documents" DROP COLUMN "created_by_id"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 017828a..b7901b2 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -18,3 +18,4 @@ export * from './1734601976591-create-allowance-entities'; export * from './1734861516657-create-gift-entities'; export * from './1734944692999-create-notification-entity-and-edit-device'; export * from './1736414850257-add-flags-to-user-entity'; +export * from './1736753223884-add_created_by_to_document_table'; diff --git a/src/document/controllers/document.controller.ts b/src/document/controllers/document.controller.ts index ac7442a..e884113 100644 --- a/src/document/controllers/document.controller.ts +++ b/src/document/controllers/document.controller.ts @@ -1,7 +1,10 @@ -import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { memoryStorage } from 'multer'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AuthenticatedUser } from '~/common/decorators'; +import { AccessTokenGuard } from '~/common/guards'; import { ResponseFactory } from '~/core/utils'; import { UploadDocumentRequestDto } from '../dtos/request'; import { DocumentMetaResponseDto } from '../dtos/response'; @@ -9,6 +12,8 @@ import { DocumentType } from '../enums'; import { DocumentService } from '../services'; @Controller('document') @ApiTags('Document') +@ApiBearerAuth() +@UseGuards(AccessTokenGuard) export class DocumentController { constructor(private readonly documentService: DocumentService) {} @@ -36,8 +41,9 @@ export class DocumentController { async createDocument( @UploadedFile() file: Express.Multer.File, @Body() uploadedDocumentRequest: UploadDocumentRequestDto, + @AuthenticatedUser() user: IJwtPayload, ) { - const document = await this.documentService.createDocument(file, uploadedDocumentRequest); + const document = await this.documentService.createDocument(file, uploadedDocumentRequest, user.sub); return ResponseFactory.data(new DocumentMetaResponseDto(document)); } diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 055799b..c523d25 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -1,4 +1,13 @@ -import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; import { Customer } from '~/customer/entities'; import { Gift } from '~/gift/entities'; import { Junior, Theme } from '~/junior/entities'; @@ -22,6 +31,9 @@ export class Document { @Column({ type: 'varchar', length: 255, name: 'document_type' }) documentType!: DocumentType; + @Column({ type: 'uuid', nullable: true, name: 'created_by_id' }) + createdById!: string; + @OneToOne(() => Customer, (customer) => customer.profilePicture, { onDelete: 'SET NULL' }) customerPicture?: Customer; @@ -46,6 +58,10 @@ export class Document { @OneToMany(() => Gift, (gift) => gift.image) gifts?: Gift[]; + @ManyToOne(() => User, (user) => user.createdDocuments, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'created_by_id' }) + createdBy?: User; + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) updatedAt!: Date; diff --git a/src/document/repositories/document.repository.ts b/src/document/repositories/document.repository.ts index e76eadd..42e383a 100644 --- a/src/document/repositories/document.repository.ts +++ b/src/document/repositories/document.repository.ts @@ -8,12 +8,13 @@ import { Document } from '../entities'; export class DocumentRepository { constructor(@InjectRepository(Document) private documentRepository: Repository) {} - createDocument(document: UploadResponseDto) { + createDocument(userId: string, document: UploadResponseDto) { return this.documentRepository.save( this.documentRepository.create({ name: document.name, documentType: document.documentType, extension: document.extension, + createdById: userId, }), ); } @@ -21,4 +22,8 @@ export class DocumentRepository { findDocuments(where: FindOptionsWhere) { return this.documentRepository.find({ where }); } + + findDocumentById(id: string) { + return this.documentRepository.findOne({ where: { id } }); + } } diff --git a/src/document/services/document.service.ts b/src/document/services/document.service.ts index 552c8ef..5378942 100644 --- a/src/document/services/document.service.ts +++ b/src/document/services/document.service.ts @@ -9,14 +9,19 @@ import { OciService } from './oci.service'; export class DocumentService { private readonly logger = new Logger(DocumentService.name); constructor(private readonly ociService: OciService, private readonly documentRepository: DocumentRepository) {} - async createDocument(file: Express.Multer.File, uploadedDocumentRequest: UploadDocumentRequestDto) { + async createDocument(file: Express.Multer.File, uploadedDocumentRequest: UploadDocumentRequestDto, userId: string) { this.logger.log(`creating document for with type ${uploadedDocumentRequest.documentType}`); const uploadedFile = await this.ociService.uploadFile(file, uploadedDocumentRequest); - return this.documentRepository.createDocument(uploadedFile); + return this.documentRepository.createDocument(userId, uploadedFile); } findDocuments(where: FindOptionsWhere) { this.logger.log(`finding documents with where clause ${JSON.stringify(where)}`); return this.documentRepository.findDocuments(where); } + + findDocumentById(id: string) { + this.logger.log(`finding document with id ${id}`); + return this.documentRepository.findDocumentById(id); + } } diff --git a/src/gift/services/gifts.service.ts b/src/gift/services/gifts.service.ts index c9a4f9a..d2dd5fc 100644 --- a/src/gift/services/gifts.service.ts +++ b/src/gift/services/gifts.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { Roles } from '~/auth/enums'; import { IJwtPayload } from '~/auth/interfaces'; -import { OciService } from '~/document/services'; +import { DocumentService, OciService } from '~/document/services'; import { JuniorService } from '~/junior/services'; import { CreateGiftRequestDto, GiftFiltersRequestDto, GiftReplyRequestDto } from '../dtos/request'; import { Gift } from '../entities'; @@ -15,10 +15,13 @@ export class GiftsService { private readonly juniorService: JuniorService, private readonly giftsRepository: GiftsRepository, private readonly ociService: OciService, + private readonly documentService: DocumentService, ) {} async createGift(guardianId: string, body: CreateGiftRequestDto) { this.logger.log(`Creating gift for junior ${body.recipientId} by guardian ${guardianId}`); + + await this.validateGiftImage(guardianId, body.imageId); const doesJuniorBelongToGuardian = await this.juniorService.doesJuniorBelongToGuardian( guardianId, body.recipientId, @@ -116,4 +119,21 @@ export class GiftsService { }), ); } + + private async validateGiftImage(userId: string, imageId?: string) { + if (!imageId) return; + + this.logger.log(`Validating gift image ${imageId}`); + const image = await this.documentService.findDocumentById(imageId); + + if (!image) { + this.logger.error(`Gift image ${imageId} not found`); + throw new BadRequestException('DOCUMENT.NOT_FOUND'); + } + + if (image.createdById && image.createdById !== userId) { + this.logger.error(`Gift image ${imageId} does not belong to user ${userId}`); + throw new BadRequestException('DOCUMENT.NOT_BELONG_TO_USER'); + } + } } diff --git a/src/junior/repositories/junior.repository.ts b/src/junior/repositories/junior.repository.ts index 1e84c18..77d6b38 100644 --- a/src/junior/repositories/junior.repository.ts +++ b/src/junior/repositories/junior.repository.ts @@ -42,4 +42,10 @@ export class JuniorRepository { findThemeForJunior(juniorId: string) { return this.juniorRepository.manager.findOne(Theme, { where: { juniorId }, relations: ['avatar'] }); } + + findJuniorByCivilId(civilIdFrontId: string, civilIdBackId: string) { + return this.juniorRepository.findOne({ + where: [{ civilIdFrontId, civilIdBackId }, { civilIdFrontId }, { civilIdBackId }], + }); + } } diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 9cb32d8..04d54f5 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -4,6 +4,7 @@ import { Roles } from '~/auth/enums'; import { PageOptionsRequestDto } from '~/core/dtos'; import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity'; import { CustomerService } from '~/customer/services'; +import { DocumentService, OciService } from '~/document/services'; import { UserService } from '~/user/services'; import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request'; import { Junior } from '../entities'; @@ -18,6 +19,8 @@ export class JuniorService { private readonly juniorTokenService: JuniorTokenService, private readonly userService: UserService, private readonly customerService: CustomerService, + private readonly documentService: DocumentService, + private readonly ociService: OciService, ) {} @Transactional() @@ -30,6 +33,8 @@ export class JuniorService { throw new BadRequestException('USER.ALREADY_EXISTS'); } + await this.validateJuniorDocuments(guardianId, body.civilIdFrontId, body.civilIdBackId); + const user = await this.userService.createUser({ email: body.email, countryCode: body.countryCode, @@ -75,6 +80,11 @@ export class JuniorService { @Transactional() async setTheme(body: SetThemeRequestDto, juniorId: string) { this.logger.log(`Setting theme for junior ${juniorId}`); + const document = await this.documentService.findDocumentById(body.avatarId); + if (!document || document.createdById !== juniorId) { + this.logger.error(`Document ${body.avatarId} not found or not created by junior ${juniorId}`); + throw new BadRequestException('DOCUMENT.NOT_FOUND'); + } const junior = await this.findJuniorById(juniorId); if (junior.theme) { this.logger.log(`Removing existing theme for junior ${juniorId}`); @@ -86,9 +96,12 @@ export class JuniorService { return this.juniorRepository.findThemeForJunior(juniorId); } - findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) { + async findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto): Promise<[Junior[], number]> { this.logger.log(`Finding juniors for guardian ${guardianId}`); - return this.juniorRepository.findJuniorsByGuardianId(guardianId, pageOptions); + const [juniors, itemCount] = await this.juniorRepository.findJuniorsByGuardianId(guardianId, pageOptions); + this.logger.log(`Juniors found for guardian ${guardianId}`); + await this.prepareJuniorImages(juniors); + return [juniors, itemCount]; } async validateToken(token: string) { @@ -108,4 +121,49 @@ export class JuniorService { 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[]) { + this.logger.log(`Preparing junior images`); + await Promise.all( + juniors.map(async (junior) => { + const profilePicture = junior.customer.profilePicture; + + if (profilePicture) { + profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); + } + }), + ); + } } diff --git a/src/task/services/task.service.ts b/src/task/services/task.service.ts index 7e1fe25..1e57bab 100644 --- a/src/task/services/task.service.ts +++ b/src/task/services/task.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import moment from 'moment'; import { FindOptionsWhere } from 'typeorm'; import { IJwtPayload } from '~/auth/interfaces'; -import { OciService } from '~/document/services'; +import { DocumentService, OciService } from '~/document/services'; import { CreateTaskRequestDto, TasksFilterOptions, TaskSubmissionRequestDto } from '../dtos/request'; import { Task } from '../entities'; import { SubmissionStatus, TaskStatus } from '../enums'; @@ -11,7 +11,11 @@ import { TaskRepository } from '../repositories'; @Injectable() export class TaskService { private readonly logger = new Logger(TaskService.name); - constructor(private readonly taskRepository: TaskRepository, private readonly ociService: OciService) {} + constructor( + private readonly taskRepository: TaskRepository, + private readonly ociService: OciService, + private readonly documentService: DocumentService, + ) {} async createTask(userId: string, body: CreateTaskRequestDto) { this.logger.log(`Creating task for user ${userId}`); if (moment(body.dueDate).isBefore(moment(body.startDate))) { @@ -23,6 +27,8 @@ export class TaskService { this.logger.error(`Due date must be in the future`); throw new BadRequestException('TASK.DUE_DATE_IN_PAST'); } + + await this.validateTaskImage(userId, body.imageId); const task = await this.taskRepository.createTask(userId, body); this.logger.log(`Task ${task.id} created successfully`); @@ -66,6 +72,8 @@ export class TaskService { throw new BadRequestException('TASK.PROOF_REQUIRED'); } + await this.validateTaskImage(userId, body.imageId); + await this.taskRepository.createSubmission(task, body); this.logger.log(`Task ${taskId} submitted successfully`); } @@ -128,4 +136,21 @@ export class TaskService { }), ); } + + private async validateTaskImage(userId: string, imageId?: string) { + if (!imageId) return; + + this.logger.log(`Validating task image ${imageId}`); + const image = await this.documentService.findDocumentById(imageId); + + if (!image) { + this.logger.error(`Task image ${imageId} not found`); + throw new BadRequestException('DOCUMENT.NOT_FOUND'); + } + + if (image.createdById && image.createdById !== userId) { + this.logger.error(`Task image ${imageId} not created by user ${userId}`); + throw new BadRequestException('DOCUMENT.NOT_CREATED_BY_USER'); + } + } } diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 0dbc676..51f1b50 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -11,6 +11,7 @@ import { import { Notification } from '~/common/modules/notification/entities'; import { Otp } from '~/common/modules/otp/entities'; import { Customer } from '~/customer/entities/customer.entity'; +import { Document } from '~/document/entities'; import { Roles } from '../../auth/enums'; import { Device } from './device.entity'; @@ -64,6 +65,9 @@ export class User extends BaseEntity { @OneToMany(() => Notification, (notification) => notification.user) notifications!: Notification[]; + @OneToMany(() => Document, (document) => document.createdBy) + createdDocuments!: Document[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) createdAt!: Date;