diff --git a/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts b/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts index ea02a1c..94661aa 100644 --- a/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts +++ b/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts @@ -1,31 +1,35 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '~/document/entities'; -import { DocumentType } from '~/document/enums'; +import { DocumentType, UploadStatus } from '~/document/enums'; const DEFAULT_TASK_LOGOS = [ { id: uuid(), name: 'bed-furniture', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'dog', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'dish-washing', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'walking-the-dog', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedsDefaultTasksLogo1733990253208 implements MigrationInterface { diff --git a/src/db/migrations/1753869637732-seed-default-avatar.ts b/src/db/migrations/1753869637732-seed-default-avatar.ts index 7b1a5e3..a6c5935 100644 --- a/src/db/migrations/1753869637732-seed-default-avatar.ts +++ b/src/db/migrations/1753869637732-seed-default-avatar.ts @@ -1,61 +1,70 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '../../document/entities'; -import { DocumentType } from '../../document/enums'; +import { DocumentType, UploadStatus } from '../../document/enums'; const DEFAULT_AVATARS = [ { id: uuid(), name: 'vacation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'colors', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'astronaut', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'pet', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'disney', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'clothes', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'playstation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'football', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'cars', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedDefaultAvatar1753869637732 implements MigrationInterface { diff --git a/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts b/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts new file mode 100644 index 0000000..10a6b67 --- /dev/null +++ b/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUploadStatusToDocumentEntity1754226754947 implements MigrationInterface { + name = 'AddUploadStatusToDocumentEntity1754226754947'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "documents" ADD "upload_status" character varying(255) NOT NULL DEFAULT 'NOT_UPLOADED'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "documents" DROP COLUMN "upload_status"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 63f3865..85c5217 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -5,3 +5,4 @@ export * from './1753869637732-seed-default-avatar'; 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'; diff --git a/src/document/controllers/document.controller.ts b/src/document/controllers/document.controller.ts index 2fc2a72..4393413 100644 --- a/src/document/controllers/document.controller.ts +++ b/src/document/controllers/document.controller.ts @@ -1,9 +1,12 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiNoContentResponse, ApiTags } from '@nestjs/swagger'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AuthenticatedUser } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { ApiLangRequestHeader } from '~/core/decorators'; +import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { GenerateUploadSignedUrlRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; +import { GenerateUploadSignedUrlResponseDto } from '../dtos/response'; import { DocumentService } from '../services'; @Controller('document') @ApiTags('Document') @@ -14,8 +17,16 @@ export class DocumentController { constructor(private readonly documentService: DocumentService) {} @Post('signed-url') - async generateSignedUrl(@Body() body: GenerateUploadSignedUrlRequestDto) { - const signedUrl = await this.documentService.generateUploadSignedUrl(body); - return ResponseFactory.data(signedUrl); + @ApiDataResponse(GenerateUploadSignedUrlResponseDto) + async generateSignedUrl(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateDocumentRequestDto) { + const result = await this.documentService.generateUploadSignedUrl(sub, body); + return ResponseFactory.data(new GenerateUploadSignedUrlResponseDto(result.document, result.uploadUrl)); + } + + @Post(':documentId/confirm-update') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiNoContentResponse({ description: 'Document update confirmed successfully' }) + async confirmDocumentUpdate(@Param('documentId') documentId: string) { + return this.documentService.confirmDocumentUpdate(documentId); } } diff --git a/src/document/dtos/request/generate-upload-signed-url.request.dto.ts b/src/document/dtos/request/create-document.request.dto.ts similarity index 88% rename from src/document/dtos/request/generate-upload-signed-url.request.dto.ts rename to src/document/dtos/request/create-document.request.dto.ts index 38e83b2..2b0bd63 100644 --- a/src/document/dtos/request/generate-upload-signed-url.request.dto.ts +++ b/src/document/dtos/request/create-document.request.dto.ts @@ -3,12 +3,12 @@ import { IsEnum, IsString } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { DocumentType } from '~/document/enums'; -export class GenerateUploadSignedUrlRequestDto { +export class CreateDocumentRequestDto { @ApiProperty({ enum: DocumentType }) @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) documentType!: DocumentType; - @ApiProperty({ type: String }) + @ApiProperty({ type: String, example: '.jpg' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.document.extension' }) }) extension!: string; diff --git a/src/document/dtos/request/index.ts b/src/document/dtos/request/index.ts index 2dd4fad..7d9b39c 100644 --- a/src/document/dtos/request/index.ts +++ b/src/document/dtos/request/index.ts @@ -1,2 +1 @@ -export * from './generate-upload-signed-url.request.dto'; -export * from './upload-document.request.dto'; +export * from './create-document.request.dto'; diff --git a/src/document/dtos/request/upload-document.request.dto.ts b/src/document/dtos/request/upload-document.request.dto.ts deleted file mode 100644 index 89210fe..0000000 --- a/src/document/dtos/request/upload-document.request.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { DocumentType } from '~/document/enums'; - -export class UploadDocumentRequestDto { - @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) - documentType!: DocumentType; -} diff --git a/src/document/dtos/response/generate-upload-signed-url.response.dto.ts b/src/document/dtos/response/generate-upload-signed-url.response.dto.ts new file mode 100644 index 0000000..4e2055e --- /dev/null +++ b/src/document/dtos/response/generate-upload-signed-url.response.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Document } from '~/document/entities'; +import { DocumentMetaResponseDto } from './document-meta.response.dto'; + +export class GenerateUploadSignedUrlResponseDto { + @ApiProperty({ type: DocumentMetaResponseDto }) + document!: DocumentMetaResponseDto; + + @ApiProperty() + uploadUrl!: string; + + constructor(document: Document, uploadUrl: string) { + this.document = new DocumentMetaResponseDto(document); + this.uploadUrl = uploadUrl; + } +} diff --git a/src/document/dtos/response/index.ts b/src/document/dtos/response/index.ts index 18a07a3..6422d12 100644 --- a/src/document/dtos/response/index.ts +++ b/src/document/dtos/response/index.ts @@ -1,2 +1,3 @@ export * from './document-meta.response.dto'; +export * from './generate-upload-signed-url.response.dto'; export * from './upload.response.dto'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 7c327f1..959f63d 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -15,13 +15,16 @@ import { SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { User } from '~/user/entities'; -import { DocumentType } from '../enums'; +import { DocumentType, UploadStatus } from '../enums'; @Entity('documents') export class Document { @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ type: 'varchar', length: 255, default: UploadStatus.NOT_UPLOADED, name: 'upload_status' }) + uploadStatus!: UploadStatus; + @Column({ type: 'varchar', length: 255 }) name!: string; diff --git a/src/document/enums/index.ts b/src/document/enums/index.ts index 173c116..e725bf4 100644 --- a/src/document/enums/index.ts +++ b/src/document/enums/index.ts @@ -1 +1,2 @@ export * from './document-type.enum'; +export * from './upload-status.enum'; diff --git a/src/document/enums/upload-status.enum.ts b/src/document/enums/upload-status.enum.ts new file mode 100644 index 0000000..c5f049e --- /dev/null +++ b/src/document/enums/upload-status.enum.ts @@ -0,0 +1,4 @@ +export enum UploadStatus { + NOT_UPLOADED = 'NOT_UPLOADED', + UPLOADED = 'UPLOADED', +} diff --git a/src/document/repositories/document.repository.ts b/src/document/repositories/document.repository.ts index 42e383a..bf9f124 100644 --- a/src/document/repositories/document.repository.ts +++ b/src/document/repositories/document.repository.ts @@ -26,4 +26,8 @@ export class DocumentRepository { findDocumentById(id: string) { return this.documentRepository.findOne({ where: { id } }); } + + updateDocument(id: string, updateData: Partial) { + return this.documentRepository.update(id, updateData); + } } diff --git a/src/document/services/document.service.ts b/src/document/services/document.service.ts index bdc72cd..027dd3c 100644 --- a/src/document/services/document.service.ts +++ b/src/document/services/document.service.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; -import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; import { Document } from '../entities'; +import { UploadStatus } from '../enums'; import { DocumentRepository } from '../repositories'; import { OciService } from './oci.service'; @@ -9,11 +10,6 @@ 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, userId: string) { - this.logger.log(`creating document for with type ${uploadedDocumentRequest.documentType}`); - const uploadedFile = await this.ociService.uploadFile(file, uploadedDocumentRequest); - return this.documentRepository.createDocument(userId, uploadedFile); - } findDocuments(where: FindOptionsWhere) { this.logger.log(`finding documents with where clause ${JSON.stringify(where)}`); @@ -25,10 +21,27 @@ export class DocumentService { return this.documentRepository.findDocumentById(id); } - generateUploadSignedUrl(body: GenerateUploadSignedUrlRequestDto) { + async confirmDocumentUpdate(documentId: string) { + const document = await this.documentRepository.findDocumentById(documentId); + if (!document) { + this.logger.error(`Document with id ${documentId} not found`); + throw new Error(`Document with id ${documentId} not found.`); + } + this.logger.log(`Confirming document update for document id ${documentId}`); + + return this.documentRepository.updateDocument(documentId, { + uploadStatus: UploadStatus.UPLOADED, + }); + } + + async generateUploadSignedUrl(userId: string, body: CreateDocumentRequestDto) { this.logger.log( `generating signed URL for document type ${body.documentType} with original file name ${body.originalFileName}`, ); - return this.ociService.generateUploadPreSignedUrl(body); + const uploadResult = await this.ociService.generateUploadPreSignedUrl(body); + + const document = await this.documentRepository.createDocument(userId, uploadResult); + + return { document, uploadUrl: uploadResult.url }; } } diff --git a/src/document/services/oci.service.ts b/src/document/services/oci.service.ts index 9c6002d..ad2f319 100644 --- a/src/document/services/oci.service.ts +++ b/src/document/services/oci.service.ts @@ -5,10 +5,9 @@ import moment from 'moment'; import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common'; import { ObjectStorageClient } from 'oci-objectstorage'; import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/model'; -import path from 'path'; import { CacheService } from '~/common/modules/cache/services'; import { BUCKETS } from '../constants'; -import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; import { UploadResponseDto } from '../dtos/response'; import { Document } from '../entities'; import { generateNewFileName } from '../utils'; @@ -37,35 +36,6 @@ export class OciService { }); } - async uploadFile(file: Express.Multer.File, { documentType }: UploadDocumentRequestDto): Promise { - this.logger.log(`Uploading file with type ${documentType}`); - const bucketName = BUCKETS[documentType]; - const objectName = generateNewFileName(file.originalname); - - if (!bucketName) { - this.logger.error('Could not find bucket name for document type', documentType); - throw new BadRequestException('DOCUMENT.TYPE_NOT_SUPPORTED'); - } - - this.logger.debug(`Uploading file to bucket ${bucketName} with object name ${objectName}`); - await this.ociClient.putObject({ - namespaceName: this.namespace, - bucketName, - putObjectBody: file.buffer, - contentLength: file.buffer.length, - objectName, - }); - - this.logger.log(`File uploaded successfully to bucket ${bucketName} with object name ${objectName}`); - - return plainToInstance(UploadResponseDto, { - name: objectName, - extension: path.extname(file.originalname), - url: `https://objectstorage.${this.region}.oraclecloud.com/n/${this.namespace}/b/${bucketName}/o/${objectName}`, - documentType, - }); - } - async generatePreSignedUrl(document?: Document): Promise { this.logger.log(`Generating pre-signed url for document ${document?.id}`); if (!document) { @@ -111,7 +81,7 @@ export class OciService { documentType, originalFileName, extension, - }: GenerateUploadSignedUrlRequestDto): Promise { + }: CreateDocumentRequestDto): Promise { const bucketName = BUCKETS[documentType]; if (!bucketName) {