feat: upload document to oci bucket

This commit is contained in:
Abdalhamid Alhamad
2024-12-02 12:07:10 +03:00
parent 14e70a63b8
commit 847c078735
33 changed files with 1247 additions and 925 deletions

View File

@ -9,6 +9,14 @@ MIGRATIONS_RUN=true
SWAGGER_API_DOCS_PATH="/api-docs"
OCI_TENANCY_ID=
OCI_USER_ID=
OCI_FINGERPRINT=
OCI_PRIVATE_KEY=
OCI_PASSPHRASE=""
OCI_NAMESPACE=
OCI_REGION=
MAIL_HOST=smtp.gmail.com
MAIL_USER=aahalhmad@gmail.com

1782
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -49,8 +49,8 @@
"nestjs-i18n": "^10.4.9",
"nestjs-pino": "^4.1.0",
"nodemailer": "^6.9.16",
"oci-common": "^2.98.1",
"oci-sdk": "^2.98.1",
"oci-common": "^2.99.0",
"oci-sdk": "^2.99.0",
"pg": "^8.13.1",
"pino-http": "^10.3.0",
"pino-pretty": "^13.0.0",

View File

@ -1,7 +1,6 @@
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { ClientProxyFactory, Transport } from '@nestjs/microservices';
import { TypeOrmModule } from '@nestjs/typeorm';
import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
import { LoggerModule } from 'nestjs-pino';
@ -10,6 +9,7 @@ import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './c
import { buildI18nOptions } from './core/module-options/i18n-options';
import { buildValidationPipe } from './core/pipes';
import { migrations } from './db';
import { DocumentModule } from './document/document.module';
import { HealthModule } from './health/health.module';
@Module({
controllers: [],
@ -26,6 +26,9 @@ import { HealthModule } from './health/health.module';
}),
I18nModule.forRoot(buildI18nOptions()),
HealthModule,
// Application Modules
DocumentModule,
],
providers: [
// Global Pipes

View File

@ -1,20 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTemplateTable1731310840593 implements MigrationInterface {
name = 'CreateTemplateTable1731310840593';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "template" (
"id" SERIAL,
"name" character varying(255) NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_ac51aa5181ee2036f5ca482857d" PRIMARY KEY ("id"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "template"`);
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateDocumentEntity1732434281561 implements MigrationInterface {
name = 'CreateDocumentEntity1732434281561';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "documents" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(255) NOT NULL,
"extension" character varying(255) NOT NULL,
"documentType" character varying(255) NOT NULL,
"accessType" character varying(255) NOT NULL,
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_ac51aa5181ee2036f5ca482857c" PRIMARY KEY ("id"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "documents"`);
}
}

View File

@ -1 +1 @@
export * from './1731310840593-create-template-table';
export * from './1732434281561-create-document-entity';

View File

@ -0,0 +1,6 @@
import { DocumentType } from '../enums';
export const BUCKETS: Record<DocumentType, string> = {
[DocumentType.PROFILE_PICTURE]: 'profile-pictures',
[DocumentType.PASSPORT]: 'passports',
};

View File

@ -0,0 +1 @@
export * from './buckets.constant';

View File

@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { memoryStorage } from 'multer';
import { ResponseFactory } from '~/core/utils';
import { UploadDocumentRequestDto } from '../dtos/request';
import { DocumentMetaResponseDto } from '../dtos/response';
import { AccessType, DocumentType } from '../enums';
import { DocumentService } from '../services';
@Controller('document')
@ApiTags('document')
export class DocumentController {
constructor(private readonly documentService: DocumentService) {}
@Post()
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
document: {
type: 'string',
format: 'binary',
},
documentType: {
type: 'string',
enum: Object.values(DocumentType),
},
accessType: {
type: 'string',
enum: Object.values(AccessType),
},
},
required: ['document', 'documentType', 'accessType'],
},
})
@UseInterceptors(FileInterceptor('document', { storage: memoryStorage() }))
async createDocument(
@UploadedFile() file: Express.Multer.File,
@Body() uploadedDocumentRequest: UploadDocumentRequestDto,
) {
const document = await this.documentService.createDocument(file, uploadedDocumentRequest);
return ResponseFactory.data(new DocumentMetaResponseDto(document));
}
@Get(':documentId')
async findDocumentById(@Param('documentId') documentId: string) {
const document = await this.documentService.findDocumentById(documentId);
console.log(document);
return ResponseFactory.data(new DocumentMetaResponseDto(document));
}
}

View File

@ -0,0 +1 @@
export * from './document.controller';

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DocumentController } from './controllers';
import { Document } from './entities';
import { DocumentRepository } from './repositories';
import { DocumentService, OciService } from './services';
@Module({
imports: [TypeOrmModule.forFeature([Document])],
controllers: [DocumentController],
providers: [DocumentService, OciService, DocumentRepository],
})
export class DocumentModule {}

View File

@ -0,0 +1 @@
export * from './upload-document.request.dto';

View File

@ -0,0 +1,11 @@
import { IsEnum } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AccessType, DocumentType } from '~/document/enums';
export class UploadDocumentRequestDto {
@IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) })
documentType!: DocumentType;
@IsEnum(AccessType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.accessType' }) })
accessType!: AccessType;
}

View File

@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger';
import { Document } from '~/document/entities';
import { AccessType, DocumentType } from '~/document/enums';
export class DocumentMetaResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiProperty()
extension!: string;
@ApiProperty()
documentType!: DocumentType;
@ApiProperty()
acessType!: AccessType;
@ApiProperty()
createdAt!: Date;
@ApiProperty()
updatedAt!: Date;
constructor(document: Document) {
this.id = document.id;
this.name = document.name;
this.extension = document.extension;
this.documentType = document.documentType;
this.acessType = document.accessType;
this.createdAt = document.createdAt;
this.updatedAt = document.updatedAt;
}
}

View File

@ -0,0 +1,2 @@
export * from './document-meta.response.dto';
export * from './upload.response.dto';

View File

@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { AccessType, DocumentType } from '~/document/enums';
export class UploadResponseDto {
@ApiProperty()
name!: string;
@ApiProperty()
documentType!: DocumentType;
@ApiProperty()
accessType!: AccessType;
@ApiProperty()
extension!: string;
@ApiProperty()
url!: string;
}

View File

@ -0,0 +1,26 @@
import { Column, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { AccessType, DocumentType } from '../enums';
@Entity('documents')
export class Document {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'varchar', length: 255 })
extension!: string;
@Column({ type: 'varchar', length: 255 })
documentType!: DocumentType;
@Column({ type: 'varchar', length: 255 })
accessType!: AccessType;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
@Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}

View File

@ -0,0 +1 @@
export * from './document.entity';

View File

@ -0,0 +1,4 @@
export enum AccessType {
PUBLIC = 'PUBLIC',
PRIVATE = 'PRIVATE',
}

View File

@ -0,0 +1,4 @@
export enum DocumentType {
PROFILE_PICTURE = 'PROFILE_PICTURE',
PASSPORT = 'PASSPORT',
}

View File

@ -0,0 +1,2 @@
export * from './access-type.enum';
export * from './document-type.enum';

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UploadResponseDto } from '../dtos/response';
import { Document } from '../entities';
@Injectable()
export class DocumentRepository {
constructor(@InjectRepository(Document) private documentRepository: Repository<Document>) {}
createDocument(document: UploadResponseDto) {
return this.documentRepository.save(
this.documentRepository.create({
name: document.name,
documentType: document.documentType,
extension: document.extension,
accessType: document.accessType,
}),
);
}
findDocumentById(documentId: string) {
return this.documentRepository.findOne({ where: { id: documentId } });
}
}

View File

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

View File

@ -0,0 +1,24 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { UploadDocumentRequestDto } from '../dtos/request';
import { DocumentRepository } from '../repositories';
import { OciService } from './oci.service';
@Injectable()
export class DocumentService {
constructor(private readonly ociService: OciService, private readonly documentRepository: DocumentRepository) {}
async createDocument(file: Express.Multer.File, uploadedDocumentRequest: UploadDocumentRequestDto) {
const uploadedFile = await this.ociService.uploadFile(file, uploadedDocumentRequest);
console.log('test');
return this.documentRepository.createDocument(uploadedFile);
}
async findDocumentById(documentId: string) {
const document = await this.documentRepository.findDocumentById(documentId);
if (!document) {
throw new NotFoundException('DOCUMENT.NOT_FOUND');
}
return document;
}
}

View File

@ -0,0 +1,2 @@
export * from './document.service';
export * from './oci.service';

View File

@ -0,0 +1,68 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { plainToInstance } from 'class-transformer';
import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common';
import { ObjectStorageClient } from 'oci-objectstorage';
import path from 'path';
import { BUCKETS } from '../constants';
import { UploadDocumentRequestDto } from '../dtos/request';
import { UploadResponseDto } from '../dtos/response';
import { generateNewFileName } from '../utils';
const ONE_DAY = Date.now() + 24 * 60 * 60 * 1000;
@Injectable()
export class OciService {
private readonly ociClient: ObjectStorageClient;
private readonly tenancyId: string = this.configService.getOrThrow('OCI_TENANCY_ID');
private readonly userId: string = this.configService.getOrThrow('OCI_USER_ID');
private readonly fingerprint: string = this.configService.getOrThrow('OCI_FINGERPRINT');
private readonly privateKey: string = this.configService.getOrThrow('OCI_PRIVATE_KEY');
private readonly passPhrase: string = this.configService.getOrThrow('OCI_PASSPHRASE');
private readonly namespace: string = this.configService.getOrThrow('OCI_NAMESPACE');
private readonly region: string = this.configService.getOrThrow('OCI_REGION');
private readonly logger = new Logger(OciService.name);
constructor(private configService: ConfigService) {
this.ociClient = new ObjectStorageClient({
authenticationDetailsProvider: new SimpleAuthenticationDetailsProvider(
this.tenancyId,
this.userId,
this.fingerprint,
Buffer.from(this.privateKey, 'base64').toString('utf-8'),
this.passPhrase,
Region.fromRegionId(this.region),
),
});
}
async uploadFile(
file: Express.Multer.File,
{ documentType, accessType }: UploadDocumentRequestDto,
): Promise<UploadResponseDto> {
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,
accessType,
});
}
}

View File

@ -0,0 +1,6 @@
import path from 'path';
export function generateNewFileName(origianlName: string): string {
const ext = path.extname(origianlName);
const nameWithoutExtension = path.basename(origianlName, ext);
return nameWithoutExtension + '-' + Date.now() + ext;
}

View File

@ -0,0 +1 @@
export * from './file-names-generator.util';

View File

@ -1,5 +1,5 @@
{
"CUSTOM": {
"TEMPLATE_ERROR": "خطأ في القالب"
}
"DOCUMENTS": {
"TYPE_NOT_SUPPORTED": "نوع الملف غير مدعوم"
}
}

View File

@ -2,8 +2,9 @@
"PROPERTY_MAPPINGS": {
"paginationPage": "رقم الصفحة",
"paginationSize": "حجم الصفحة",
"test": {
"name": "الاسم"
"document": {
"documentType": "نوع الملف",
"accessType": "نوع الوصول"
}
},
"UNAUTHORIZED_ERROR": "يجب تسجيل الدخول مره اخرى",

View File

@ -1,5 +1,5 @@
{
"CUSTOM": {
"TEMPLATE_ERROR": "Template error"
"DOCUMENTS": {
"TYPE_NOT_SUPPORTED": "Document type is not supported"
}
}

View File

@ -2,9 +2,9 @@
"PROPERTY_MAPPINGS": {
"paginationPage": "page",
"paginationSize": "page size",
"test": {
"name": "name",
"age": "age"
"document": {
"documentType": "Document type",
"accessType": "Access type"
}
},
"UNAUTHORIZED_ERROR": "You have to login again",