diff --git a/packages/nestjs-file/package.json b/packages/nestjs-file/package.json index 2a95488e..9d672f82 100644 --- a/packages/nestjs-file/package.json +++ b/packages/nestjs-file/package.json @@ -15,6 +15,7 @@ "@concepta/nestjs-exception": "^5.0.0-alpha.3", "@concepta/nestjs-typeorm-ext": "^5.0.0-alpha.3", "@concepta/ts-common": "^5.0.0-alpha.3", + "@concepta/ts-core": "^5.0.0-alpha.3", "@concepta/typeorm-common": "^5.0.0-alpha.3", "@nestjs/common": "^10.4.1", "@nestjs/config": "^3.2.3", diff --git a/packages/nestjs-file/src/__fixtures__/aws-storage.service.ts b/packages/nestjs-file/src/__fixtures__/aws-storage.service.ts index 4bef61fb..09a70312 100644 --- a/packages/nestjs-file/src/__fixtures__/aws-storage.service.ts +++ b/packages/nestjs-file/src/__fixtures__/aws-storage.service.ts @@ -1,4 +1,4 @@ -import { FileInterface } from '@concepta/ts-common'; +import { FileCreatableInterface } from '@concepta/ts-common'; import { FileStorageServiceInterface } from '../interfaces/file-storage-service.interface'; import { AWS_KEY_FIXTURE, @@ -9,11 +9,11 @@ import { export class AwsStorageService implements FileStorageServiceInterface { KEY: string = AWS_KEY_FIXTURE; - getUploadUrl(_file: FileInterface): string { + getUploadUrl(_file: FileCreatableInterface): string { return UPLOAD_URL_FIXTURE; } - getDownloadUrl(_file: FileInterface): string { + getDownloadUrl(_file: FileCreatableInterface): string { // make a call to aws to get signed download url return DOWNLOAD_URL_FIXTURE; } diff --git a/packages/nestjs-file/src/exceptions/file-create.exception.ts b/packages/nestjs-file/src/exceptions/file-create.exception.ts index 8129b0c1..8174e4bd 100644 --- a/packages/nestjs-file/src/exceptions/file-create.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-create.exception.ts @@ -1,15 +1,13 @@ -import { HttpStatus } from '@nestjs/common'; -import { RuntimeException } from '@concepta/nestjs-exception'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; export class FileCreateException extends RuntimeException { - constructor( - message = 'Error while trying to create a file', - originalError: unknown, - ) { + constructor(options?: RuntimeExceptionOptions) { super({ - message, - originalError, - httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Error while trying to create a file', + ...options, }); this.errorCode = 'FILE_CREATE_ERROR'; diff --git a/packages/nestjs-file/src/exceptions/file-download-url-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-download-url-missing.exception.ts new file mode 100644 index 00000000..f152415e --- /dev/null +++ b/packages/nestjs-file/src/exceptions/file-download-url-missing.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class FileDownloadUrlMissingException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Error trying to generate signed download url', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'FILE_DOWNLOAD_URL_ERROR'; + } +} diff --git a/packages/nestjs-file/src/exceptions/file-name-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-name-missing.exception.ts new file mode 100644 index 00000000..effe405e --- /dev/null +++ b/packages/nestjs-file/src/exceptions/file-name-missing.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class FilenameMissingException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Filename is missing.', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'FILENAME_MISSING_ERROR'; + } +} diff --git a/packages/nestjs-file/src/exceptions/file-query.exception.ts b/packages/nestjs-file/src/exceptions/file-query.exception.ts index 10513e28..744307aa 100644 --- a/packages/nestjs-file/src/exceptions/file-query.exception.ts +++ b/packages/nestjs-file/src/exceptions/file-query.exception.ts @@ -1,11 +1,15 @@ import { HttpStatus } from '@nestjs/common'; -import { RuntimeException } from '@concepta/nestjs-exception'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; export class FileQueryException extends RuntimeException { - constructor(message = 'Error while trying to do a query to file') { + constructor(options?: RuntimeExceptionOptions) { super({ - message, + message: 'Error while trying to do a query to file', httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + ...options, }); this.errorCode = 'FILE_QUERY_ERROR'; diff --git a/packages/nestjs-file/src/exceptions/file-service-key-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-service-key-missing.exception.ts new file mode 100644 index 00000000..d6aecc0e --- /dev/null +++ b/packages/nestjs-file/src/exceptions/file-service-key-missing.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class FileServiceKeyMissingException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Service key is missing.', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'SERVICE_KEY_MISSING_ERROR'; + } +} diff --git a/packages/nestjs-file/src/exceptions/file-upload-url-missing.exception.ts b/packages/nestjs-file/src/exceptions/file-upload-url-missing.exception.ts new file mode 100644 index 00000000..fe426ca1 --- /dev/null +++ b/packages/nestjs-file/src/exceptions/file-upload-url-missing.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class FileUploadUrlMissingException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Error trying to generate signed upload url', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'FILE_UPLOAD_URL_ERROR'; + } +} diff --git a/packages/nestjs-file/src/file.module-definition.ts b/packages/nestjs-file/src/file.module-definition.ts index e46b53a4..2dc31fad 100644 --- a/packages/nestjs-file/src/file.module-definition.ts +++ b/packages/nestjs-file/src/file.module-definition.ts @@ -21,6 +21,8 @@ import { FileService } from './services/file.service'; import { FileStrategyService } from './services/file-strategy.service'; import { fileDefaultConfig } from './config/file-default.config'; +import { FileMutateService } from './services/file-mutate.service'; +import { FileLookupService } from './services/file-lookup.service'; const RAW_OPTIONS_TOKEN = Symbol('__FILE_MODULE_RAW_OPTIONS_TOKEN__'); @@ -43,7 +45,7 @@ function definitionTransform( definition: DynamicModule, extras: FileOptionsExtrasInterface, ): DynamicModule { - const { providers = [] } = definition; + const { providers = [], imports = [] } = definition; const { global = false, entities } = extras; if (!entities) { @@ -53,16 +55,17 @@ function definitionTransform( return { ...definition, global, - imports: createFileImports({ entities }), + imports: createFileImports({ imports, entities }), providers: createFileProviders({ providers }), exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createFileExports()], }; } export function createFileImports( - options: FileEntitiesOptionsInterface, + options: Pick & FileEntitiesOptionsInterface, ): DynamicModule['imports'] { return [ + ...(options.imports ?? []), ConfigModule.forFeature(fileDefaultConfig), TypeOrmExtModule.forFeature(options.entities), ]; @@ -82,6 +85,10 @@ export function createFileProviders(options: { ...(options.providers ?? []), createFileSettingsProvider(options.overrides), createStrategyServiceProvider(options.overrides), + // TODO: move to be overwrittable + FileMutateService, + // TODO: move to be overwrittable + FileLookupService, FileStrategyService, FileService, ]; diff --git a/packages/nestjs-file/src/interfaces/file-lookup-service.interface.ts b/packages/nestjs-file/src/interfaces/file-lookup-service.interface.ts new file mode 100644 index 00000000..fe872dc9 --- /dev/null +++ b/packages/nestjs-file/src/interfaces/file-lookup-service.interface.ts @@ -0,0 +1,11 @@ +import { FileCreatableInterface, FileInterface } from '@concepta/ts-common'; +import { LookupIdInterface, ReferenceId } from '@concepta/ts-core'; +import { QueryOptionsInterface } from '@concepta/typeorm-common'; + +export interface FileLookupServiceInterface + extends LookupIdInterface { + getUniqueFile( + org: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise; +} diff --git a/packages/nestjs-file/src/interfaces/file-mutate-service.interface.ts b/packages/nestjs-file/src/interfaces/file-mutate-service.interface.ts new file mode 100644 index 00000000..608fd63f --- /dev/null +++ b/packages/nestjs-file/src/interfaces/file-mutate-service.interface.ts @@ -0,0 +1,6 @@ +import { FileCreatableInterface } from '@concepta/ts-common'; +import { CreateOneInterface } from '@concepta/ts-core'; +import { FileEntityInterface } from './file-entity.interface'; + +export interface FileMutateServiceInterface + extends CreateOneInterface {} diff --git a/packages/nestjs-file/src/interfaces/file-service.interface.ts b/packages/nestjs-file/src/interfaces/file-service.interface.ts index 81104244..24df6f68 100644 --- a/packages/nestjs-file/src/interfaces/file-service.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-service.interface.ts @@ -1,6 +1,7 @@ -import { FileInterface } from '@concepta/ts-common'; +import { FileCreatableInterface, FileInterface } from '@concepta/ts-common'; +import { ReferenceIdInterface } from '@concepta/ts-core'; export interface FileServiceInterface { - push(file: Omit): Promise; - fetch(file: Omit): Promise; + push(file: FileCreatableInterface): Promise; + fetch(file: ReferenceIdInterface): Promise; } diff --git a/packages/nestjs-file/src/interfaces/file-strategy-service.interface.ts b/packages/nestjs-file/src/interfaces/file-strategy-service.interface.ts index a5013a0a..c67be4d9 100644 --- a/packages/nestjs-file/src/interfaces/file-strategy-service.interface.ts +++ b/packages/nestjs-file/src/interfaces/file-strategy-service.interface.ts @@ -1,8 +1,10 @@ -import { FileInterface } from '@concepta/ts-common'; +import { FileCreatableInterface } from '@concepta/ts-common'; import { FileStorageServiceInterface } from './file-storage-service.interface'; export interface FileStrategyServiceInterface { - getUploadUrl(file: FileInterface): Promise; - getDownloadUrl(file: FileInterface): Promise; - resolveStorageService(file: FileInterface): FileStorageServiceInterface; + getUploadUrl(file: FileCreatableInterface): Promise; + getDownloadUrl(file: FileCreatableInterface): Promise; + resolveStorageService( + file: FileCreatableInterface, + ): FileStorageServiceInterface; } diff --git a/packages/nestjs-file/src/services/file-lookup.service.ts b/packages/nestjs-file/src/services/file-lookup.service.ts new file mode 100644 index 00000000..c30ba65c --- /dev/null +++ b/packages/nestjs-file/src/services/file-lookup.service.ts @@ -0,0 +1,47 @@ +import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; +import { LookupService, QueryOptionsInterface } from '@concepta/typeorm-common'; +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; + +import { FILE_MODULE_FILE_ENTITY_KEY } from '../file.constants'; +import { FileEntityInterface } from '../interfaces/file-entity.interface'; +import { FileLookupServiceInterface } from '../interfaces/file-lookup-service.interface'; +import { FileInterface } from '@concepta/ts-common'; +import { FileServiceKeyMissingException } from '../exceptions/file-service-key-missing.exception'; +import { FilenameMissingException } from '../exceptions/file-name-missing.exception'; + +/** + * File lookup service + */ +@Injectable() +export class FileLookupService + extends LookupService + implements FileLookupServiceInterface +{ + constructor( + @InjectDynamicRepository(FILE_MODULE_FILE_ENTITY_KEY) + repo: Repository, + ) { + super(repo); + } + async getUniqueFile( + file: Pick, + queryOptions?: QueryOptionsInterface, + ) { + if (!file.serviceKey) { + throw new FileServiceKeyMissingException(); + } + if (!file.fileName) { + throw new FilenameMissingException(); + } + return this.findOne( + { + where: { + serviceKey: file.serviceKey, + fileName: file.fileName, + }, + }, + queryOptions, + ); + } +} diff --git a/packages/nestjs-file/src/services/file-mutate.service.ts b/packages/nestjs-file/src/services/file-mutate.service.ts new file mode 100644 index 00000000..d5078eb1 --- /dev/null +++ b/packages/nestjs-file/src/services/file-mutate.service.ts @@ -0,0 +1,38 @@ +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { MutateService } from '@concepta/typeorm-common'; +import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; +import { FileCreatableInterface } from '@concepta/ts-common'; +import { FileEntityInterface } from '../interfaces/file-entity.interface'; + +import { FileCreateDto } from '../dto/file-create.dto'; +import { FileMutateServiceInterface } from '../interfaces/file-mutate-service.interface'; +import { FILE_MODULE_FILE_ENTITY_KEY } from '../file.constants'; + +/** + * File mutate service + */ +@Injectable() +export class FileMutateService + extends MutateService< + FileEntityInterface, + FileCreatableInterface, + FileCreatableInterface + > + implements FileMutateServiceInterface +{ + protected createDto = FileCreateDto; + protected updateDto = FileCreateDto; + + /** + * Constructor + * + * @param repo - instance of the file repo + */ + constructor( + @InjectDynamicRepository(FILE_MODULE_FILE_ENTITY_KEY) + repo: Repository, + ) { + super(repo); + } +} diff --git a/packages/nestjs-file/src/services/file-strategy.service.ts b/packages/nestjs-file/src/services/file-strategy.service.ts index 4f404893..7e8f589b 100644 --- a/packages/nestjs-file/src/services/file-strategy.service.ts +++ b/packages/nestjs-file/src/services/file-strategy.service.ts @@ -2,6 +2,7 @@ import { FileCreatableInterface } from '@concepta/ts-common'; import { FileStrategyServiceInterface } from '../interfaces/file-strategy-service.interface'; import { FileStorageServiceInterface } from '../interfaces/file-storage-service.interface'; import { FileStorageServiceNotFoundException } from '../exceptions/file-storage-service-not-found.exception'; +import { FileDownloadUrlMissingException } from '../exceptions/file-download-url-missing.exception'; export class FileStrategyService implements FileStrategyServiceInterface { private readonly storageServices: FileStorageServiceInterface[] = []; @@ -11,11 +12,23 @@ export class FileStrategyService implements FileStrategyServiceInterface { } async getUploadUrl(file: FileCreatableInterface): Promise { - return this.resolveStorageService(file).getUploadUrl(file); + try { + return this.resolveStorageService(file).getUploadUrl(file); + } catch (err) { + throw new FileDownloadUrlMissingException({ + originalError: err, + }); + } } async getDownloadUrl(file: FileCreatableInterface): Promise { - return this.resolveStorageService(file).getDownloadUrl(file); + try { + return this.resolveStorageService(file).getDownloadUrl(file); + } catch (err) { + throw new FileDownloadUrlMissingException({ + originalError: err, + }); + } } resolveStorageService( diff --git a/packages/nestjs-file/src/services/file.service.spec.ts b/packages/nestjs-file/src/services/file.service.spec.ts index 5e590cb0..2765d240 100644 --- a/packages/nestjs-file/src/services/file.service.spec.ts +++ b/packages/nestjs-file/src/services/file.service.spec.ts @@ -1,17 +1,23 @@ -import { Repository } from 'typeorm'; -import { FileService } from './file.service'; -import { FileStrategyService } from './file-strategy.service'; +import { FileCreatableInterface } from '@concepta/ts-common'; +import { TransactionProxy } from '@concepta/typeorm-common'; +import { randomUUID } from 'crypto'; +import { mock } from 'jest-mock-extended'; +import { DataSource, EntityManager, Repository } from 'typeorm'; +import { FileEntityFixture } from '../__fixtures__/file/file-entity.fixture'; import { FileCreateDto } from '../dto/file-create.dto'; import { FileQueryException } from '../exceptions/file-query.exception'; import { FileEntityInterface } from '../interfaces/file-entity.interface'; -import { mock } from 'jest-mock-extended'; -import { FileCreatableInterface } from '@concepta/ts-common'; -import { randomUUID } from 'crypto'; +import { FileLookupService } from './file-lookup.service'; +import { FileMutateService } from './file-mutate.service'; +import { FileStrategyService } from './file-strategy.service'; +import { FileService } from './file.service'; describe(FileService.name, () => { let fileService: FileService; let fileRepo: jest.Mocked>; let fileStrategyService: jest.Mocked; + let fileMutateService: FileMutateService; + let fileLookupService: FileLookupService; const mockFile: FileEntityInterface = { id: randomUUID(), @@ -35,14 +41,18 @@ describe(FileService.name, () => { beforeEach(() => { fileRepo = createMockRepository(); fileStrategyService = createMockFileStrategyService(); - fileService = new FileService(fileRepo, fileStrategyService); - fileRepo.create.mockReturnValue(mockFile); - const mockTransactionalEntityManager = { - findOne: jest.fn().mockResolvedValue(null), - create: jest.fn().mockReturnValue(mockFile), - save: jest.fn().mockResolvedValue(mockFile), - }; + fileMutateService = new FileMutateService(fileRepo); + fileLookupService = new FileLookupService(fileRepo); + fileService = new FileService( + fileStrategyService, + fileMutateService, + fileLookupService, + ); + fileRepo.create.mockReturnValue(mockFile); + const mockTransactionalEntityManager = mock({ + connection: mock(), + }); fileRepo.manager.transaction = jest.fn().mockImplementation(async (cb) => { return await cb(mockTransactionalEntityManager); }); @@ -55,6 +65,30 @@ describe(FileService.name, () => { return Promise.resolve(mockFile.uploadUri || ''); }, ); + interface MockTransaction {} + const mockTransaction = mock(); + const mockTransactionProxy = mock({ + commit: jest + .fn() + .mockImplementation((callback) => callback(mockTransaction)), + }); + mockTransactionProxy.repository(fileRepo); + + jest + .spyOn(mockTransactionProxy, 'repository') + .mockImplementationOnce(() => { + return fileRepo; + }); + jest + .spyOn(fileMutateService, 'transaction') + .mockReturnValue(mockTransactionProxy); + jest + .spyOn(fileLookupService, 'getUniqueFile') + .mockReturnValueOnce(Promise.resolve(null)) + .mockReturnValueOnce(Promise.resolve(mockFile)); + jest + .spyOn(fileMutateService, 'create') + .mockReturnValue(Promise.resolve(mockFile)); const result = await fileService.push(mockFileCreateDto); @@ -95,7 +129,7 @@ describe(FileService.name, () => { }); function createMockRepository(): jest.Mocked> { - return mock>(); + return mock>(); } function createMockFileStrategyService(): jest.Mocked { diff --git a/packages/nestjs-file/src/services/file.service.ts b/packages/nestjs-file/src/services/file.service.ts index bec0ef82..a21dfd9d 100644 --- a/packages/nestjs-file/src/services/file.service.ts +++ b/packages/nestjs-file/src/services/file.service.ts @@ -1,72 +1,68 @@ -import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; -import { BaseService } from '@concepta/typeorm-common'; import { Inject, Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; -import { FileInterface } from '@concepta/ts-common'; +import { FileCreatableInterface, FileInterface } from '@concepta/ts-common'; -import { - FILE_MODULE_FILE_ENTITY_KEY, - FILE_STRATEGY_SERVICE_KEY, -} from '../file.constants'; -import { FileEntityInterface } from '../interfaces/file-entity.interface'; -import { FileServiceInterface } from '../interfaces/file-service.interface'; -import { FileQueryException } from '../exceptions/file-query.exception'; -import { FileCreateDto } from '../dto/file-create.dto'; -import { FileStrategyService } from './file-strategy.service'; +import { ReferenceIdInterface } from '@concepta/ts-core'; import { FileCreateException } from '../exceptions/file-create.exception'; -import { FileIdMissingException } from '../exceptions/file-id-missing.exception'; import { FileDuplicateEntryException } from '../exceptions/file-duplicated.exception'; +import { FileIdMissingException } from '../exceptions/file-id-missing.exception'; +import { FileQueryException } from '../exceptions/file-query.exception'; +import { FILE_STRATEGY_SERVICE_KEY } from '../file.constants'; +import { FileLookupServiceInterface } from '../interfaces/file-lookup-service.interface'; +import { FileMutateServiceInterface } from '../interfaces/file-mutate-service.interface'; +import { FileServiceInterface } from '../interfaces/file-service.interface'; +import { FileStrategyServiceInterface } from '../interfaces/file-strategy-service.interface'; +import { FileLookupService } from './file-lookup.service'; +import { FileMutateService } from './file-mutate.service'; @Injectable() -export class FileService - extends BaseService - implements FileServiceInterface -{ +export class FileService implements FileServiceInterface { constructor( - @InjectDynamicRepository(FILE_MODULE_FILE_ENTITY_KEY) - private fileRepo: Repository, @Inject(FILE_STRATEGY_SERVICE_KEY) - private fileStrategyService: FileStrategyService, - ) { - super(fileRepo); - } + private fileStrategyService: FileStrategyServiceInterface, + @Inject(FileMutateService) + private fileMutateService: FileMutateServiceInterface, + @Inject(FileLookupService) + private fileLookupService: FileLookupServiceInterface, + ) {} - async push(file: FileCreateDto): Promise { + async push(file: FileCreatableInterface): Promise { + await this.checkExistingFile(file); try { - return await this.fileRepo.manager.transaction(async (manager) => { - const existingFile = await manager.findOne(this.fileRepo.target, { - where: { - serviceKey: file.serviceKey, - fileName: file.fileName, - }, - }); - if (existingFile) { - throw new FileDuplicateEntryException(file.serviceKey, file.fileName); - } - const newFile = manager.create(this.fileRepo.target, file); - await manager.save(newFile); - return this.addFileUrls(newFile); - }); + const newFile = await this.fileMutateService.create(file); + return this.addFileUrls(newFile); } catch (err) { - throw new FileCreateException(this.metadata.targetName, err); + throw new FileCreateException({ originalError: err }); } } - async fetch(file: Pick): Promise { + async fetch(file: ReferenceIdInterface): Promise { if (!file.id) throw new FileIdMissingException(); - const dbFile = await this.fileRepo.findOne({ - where: { - id: file.id, - }, - }); + const dbFile = await this.fileLookupService.byId(file.id); if (!dbFile) throw new FileQueryException(); return this.addFileUrls(dbFile); } - private async addFileUrls(file: FileEntityInterface): Promise { - file.uploadUri = await this.fileStrategyService.getUploadUrl(file); - file.downloadUrl = await this.fileStrategyService.getDownloadUrl(file); + protected async checkExistingFile( + file: FileCreatableInterface, + ): Promise { + const existingFile = await this.fileLookupService.getUniqueFile(file); + if (existingFile) { + throw new FileDuplicateEntryException(file.serviceKey, file.fileName); + } + } + + private async addFileUrls(file: FileInterface): Promise { + try { + file.uploadUri = await this.fileStrategyService.getUploadUrl(file); + } catch (err) { + file.uploadUri = ''; + } + try { + file.downloadUrl = await this.fileStrategyService.getDownloadUrl(file); + } catch (err) { + file.downloadUrl = ''; + } return file; } } diff --git a/packages/nestjs-report/README.md b/packages/nestjs-report/README.md new file mode 100644 index 00000000..1d4b7706 --- /dev/null +++ b/packages/nestjs-report/README.md @@ -0,0 +1,15 @@ +# Rockets NestJS Report Manager + +Manage reports for several components using one module. + +## Project + +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-report)](https://www.npmjs.com/package/@concepta/nestjs-report) +[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-report)](https://www.npmjs.com/package/@concepta/nestjs-report) +[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) +[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&reportname=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Installation + +`yarn add @concepta/nestjs-report` diff --git a/packages/nestjs-report/package.json b/packages/nestjs-report/package.json new file mode 100644 index 00000000..25ad1dcb --- /dev/null +++ b/packages/nestjs-report/package.json @@ -0,0 +1,36 @@ +{ + "name": "@concepta/nestjs-report", + "version": "5.0.0-alpha.3", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "reports": [ + "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + ], + "dependencies": { + "@concepta/nestjs-common": "^5.0.0-alpha.3", + "@concepta/nestjs-exception": "^5.0.0-alpha.3", + "@concepta/nestjs-file": "^5.0.0-alpha.3", + "@concepta/nestjs-typeorm-ext": "^5.0.0-alpha.3", + "@concepta/ts-common": "^5.0.0-alpha.3", + "@concepta/ts-core": "^5.0.0-alpha.3", + "@concepta/typeorm-common": "^5.0.0-alpha.3", + "@nestjs/common": "^10.4.1", + "@nestjs/config": "^3.2.3", + "@nestjs/swagger": "^7.4.0" + }, + "devDependencies": { + "@concepta/nestjs-user": "^5.0.0-alpha.3", + "@nestjs/testing": "^10.4.1", + "jest-mock-extended": "^2.0.9" + }, + "peerDependencies": { + "class-transformer": "*", + "class-validator": "*", + "rxjs": "^7.1.0", + "typeorm": "^0.3.0" + } +} diff --git a/packages/nestjs-report/src/__fixtures__/aws-storage.service.ts b/packages/nestjs-report/src/__fixtures__/aws-storage.service.ts new file mode 100644 index 00000000..05c48d30 --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/aws-storage.service.ts @@ -0,0 +1,21 @@ +import { FileInterface } from '@concepta/ts-common'; +import { FileStorageServiceInterface } from '@concepta/nestjs-file'; + +import { + AWS_KEY_FIXTURE, + DOWNLOAD_URL_FIXTURE, + UPLOAD_URL_FIXTURE, +} from './constants.fixture'; + +export class AwsStorageService implements FileStorageServiceInterface { + KEY: string = AWS_KEY_FIXTURE; + + getUploadUrl(_file: FileInterface): string { + return UPLOAD_URL_FIXTURE; + } + + getDownloadUrl(_file: FileInterface): string { + // make a call to aws to get signed download url + return DOWNLOAD_URL_FIXTURE; + } +} diff --git a/packages/nestjs-report/src/__fixtures__/constants.fixture.ts b/packages/nestjs-report/src/__fixtures__/constants.fixture.ts new file mode 100644 index 00000000..4680300e --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/constants.fixture.ts @@ -0,0 +1,6 @@ +export const AWS_KEY_FIXTURE = 'my-aws'; +export const REPORT_KEY_FIXTURE = 'my-report'; +export const REPORT_SHORT_DELAY_KEY_FIXTURE = 'my-report-short-delay'; +export const DOWNLOAD_URL_FIXTURE = 'https://aws-storage.com/downloaded'; +export const UPLOAD_URL_FIXTURE = 'https://aws-storage.com/upload'; +export const REPORT_NAME_FIXTURE = 'test.pdf'; diff --git a/packages/nestjs-report/src/__fixtures__/file/file-entity.fixture.ts b/packages/nestjs-report/src/__fixtures__/file/file-entity.fixture.ts new file mode 100644 index 00000000..9a9ecacc --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/file/file-entity.fixture.ts @@ -0,0 +1,10 @@ +import { Entity, OneToOne } from 'typeorm'; +import { FileSqliteEntity } from '@concepta/nestjs-file'; +import { ReportEntityFixture } from '../report/report-entity.fixture'; +import { ReportEntityInterface } from '../../interfaces/report-entity.interface'; + +@Entity() +export class FileEntityFixture extends FileSqliteEntity { + @OneToOne(() => ReportEntityFixture, (report) => report.file) + report!: ReportEntityInterface; +} diff --git a/packages/nestjs-report/src/__fixtures__/my-report-generator-short-delay.service.ts b/packages/nestjs-report/src/__fixtures__/my-report-generator-short-delay.service.ts new file mode 100644 index 00000000..2e1e24ff --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/my-report-generator-short-delay.service.ts @@ -0,0 +1,46 @@ +import { Inject } from '@nestjs/common'; +import { ReportInterface, ReportStatusEnum } from '@concepta/ts-common'; +import { FileService } from '@concepta/nestjs-file'; +import { ReportGeneratorResultInterface } from '../interfaces/report-generator-result.interface'; +import { ReportGeneratorServiceInterface } from '../interfaces/report-generator-service.interface'; +import { + AWS_KEY_FIXTURE, + REPORT_SHORT_DELAY_KEY_FIXTURE, +} from './constants.fixture'; +import { delay } from '../utils/delay.util'; + +export class MyReportGeneratorShortDelayService + implements ReportGeneratorServiceInterface +{ + constructor( + @Inject(FileService) + private readonly fileService: FileService, + ) {} + + KEY: string = REPORT_SHORT_DELAY_KEY_FIXTURE; + + generateTimeout: number = 100; + + async getDownloadUrl(report: ReportInterface): Promise { + const file = await this.fileService.fetch({ id: report.id }); + return file.downloadUrl || ''; + } + + async generate( + report: ReportInterface, + ): Promise { + const file = await this.fileService.push({ + fileName: report.name, + // TODO: should i have contenType on reports as well? + contentType: 'application/pdf', + serviceKey: AWS_KEY_FIXTURE, + }); + + await delay(200); + return { + id: report.id, + status: ReportStatusEnum.Complete, + file, + } as unknown as ReportGeneratorResultInterface; + } +} diff --git a/packages/nestjs-report/src/__fixtures__/my-report-generator.service.ts b/packages/nestjs-report/src/__fixtures__/my-report-generator.service.ts new file mode 100644 index 00000000..a8e2e172 --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/my-report-generator.service.ts @@ -0,0 +1,44 @@ +import { Inject } from '@nestjs/common'; +import { ReportInterface, ReportStatusEnum } from '@concepta/ts-common'; +import { FileService } from '@concepta/nestjs-file'; +import { ReportGeneratorResultInterface } from '../interfaces/report-generator-result.interface'; +import { ReportGeneratorServiceInterface } from '../interfaces/report-generator-service.interface'; +import { AWS_KEY_FIXTURE, REPORT_KEY_FIXTURE } from './constants.fixture'; + +export class MyReportGeneratorService + implements ReportGeneratorServiceInterface +{ + constructor( + @Inject(FileService) + private readonly fileService: FileService, + ) {} + + KEY: string = REPORT_KEY_FIXTURE; + generateTimeout: number = 60000; + + async getDownloadUrl(report: ReportInterface): Promise { + if (!report?.file?.id) return ''; + const file = await this.fileService.fetch({ id: report.file.id }); + return file.downloadUrl || ''; + } + + async generate( + report: ReportInterface, + ): Promise { + const file = await this.fileService.push({ + fileName: report.name, + // TODO: should i have contenType on reports as well? + contentType: 'application/pdf', + serviceKey: AWS_KEY_FIXTURE, + }); + + // Logic to generate file + // PUT file + + return { + id: report.id, + status: ReportStatusEnum.Complete, + file, + } as unknown as ReportGeneratorResultInterface; + } +} diff --git a/packages/nestjs-report/src/__fixtures__/report-generator.module.fixture.ts b/packages/nestjs-report/src/__fixtures__/report-generator.module.fixture.ts new file mode 100644 index 00000000..2007e691 --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/report-generator.module.fixture.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { MyReportGeneratorService } from './my-report-generator.service'; +import { MyReportGeneratorShortDelayService } from './my-report-generator-short-delay.service'; + +@Global() +@Module({ + providers: [MyReportGeneratorService, MyReportGeneratorShortDelayService], + exports: [MyReportGeneratorService, MyReportGeneratorShortDelayService], +}) +export class ReportGeneratorModuleFixture {} diff --git a/packages/nestjs-report/src/__fixtures__/report/report-entity.fixture.ts b/packages/nestjs-report/src/__fixtures__/report/report-entity.fixture.ts new file mode 100644 index 00000000..4d85d623 --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/report/report-entity.fixture.ts @@ -0,0 +1,11 @@ +import { Entity, JoinColumn, OneToOne } from 'typeorm'; +import { FileEntityInterface } from '@concepta/nestjs-file'; +import { ReportSqliteEntity } from '../../entities/report-sqlite.entity'; +import { FileEntityFixture } from '../file/file-entity.fixture'; + +@Entity() +export class ReportEntityFixture extends ReportSqliteEntity { + @OneToOne(() => FileEntityFixture, (file) => file.report) + @JoinColumn() + file!: FileEntityInterface; +} diff --git a/packages/nestjs-report/src/__fixtures__/user/entities/user.entity.fixture.ts b/packages/nestjs-report/src/__fixtures__/user/entities/user.entity.fixture.ts new file mode 100644 index 00000000..b3513441 --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/user/entities/user.entity.fixture.ts @@ -0,0 +1,9 @@ +import { Entity, OneToOne } from 'typeorm'; +import { UserSqliteEntity } from '@concepta/nestjs-user'; +import { ReportEntityFixture } from '../../report/report-entity.fixture'; + +@Entity() +export class UserEntityFixture extends UserSqliteEntity { + @OneToOne(() => ReportEntityFixture) + document!: ReportEntityFixture; +} diff --git a/packages/nestjs-report/src/__fixtures__/user/user.fixture.ts b/packages/nestjs-report/src/__fixtures__/user/user.fixture.ts new file mode 100644 index 00000000..9eae02f8 --- /dev/null +++ b/packages/nestjs-report/src/__fixtures__/user/user.fixture.ts @@ -0,0 +1,5 @@ +export const UserFixture = { + id: 'abc', + email: 'me@dispostable.com', + username: 'me@dispostable.com', +}; diff --git a/packages/nestjs-report/src/config/report-default.config.ts b/packages/nestjs-report/src/config/report-default.config.ts new file mode 100644 index 00000000..beaa33ed --- /dev/null +++ b/packages/nestjs-report/src/config/report-default.config.ts @@ -0,0 +1,15 @@ +import { registerAs } from '@nestjs/config'; +import { REPORT_MODULE_DEFAULT_SETTINGS_TOKEN } from '../report.constants'; +import { ReportSettingsInterface } from '../interfaces/report-settings.interface'; + +/** + * Default configuration for report module. + */ +export const reportDefaultConfig = registerAs( + REPORT_MODULE_DEFAULT_SETTINGS_TOKEN, + (): ReportSettingsInterface => ({ + generateTimeout: process.env?.REPORT_MODULE_TIMEOUT_IN_SECONDS + ? Number(process.env.REPORT_MODULE_TIMEOUT_IN_SECONDS) + : 60000, + }), +); diff --git a/packages/nestjs-report/src/dto/report-create.dto.ts b/packages/nestjs-report/src/dto/report-create.dto.ts new file mode 100644 index 00000000..2c2e5c56 --- /dev/null +++ b/packages/nestjs-report/src/dto/report-create.dto.ts @@ -0,0 +1,12 @@ +import { ReportCreatableInterface } from '@concepta/ts-common'; +import { PickType } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; +import { ReportDto } from './report.dto'; + +/** + * Report Create DTO + */ +@Exclude() +export class ReportCreateDto + extends PickType(ReportDto, ['serviceKey', 'name', 'status'] as const) + implements ReportCreatableInterface {} diff --git a/packages/nestjs-report/src/dto/report-update.dto.ts b/packages/nestjs-report/src/dto/report-update.dto.ts new file mode 100644 index 00000000..57c73d97 --- /dev/null +++ b/packages/nestjs-report/src/dto/report-update.dto.ts @@ -0,0 +1,15 @@ +import { ReportUpdatableInterface } from '@concepta/ts-common/src/report/interfaces/report-updatable.interface'; +import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; +import { ReportDto } from './report.dto'; + +/** + * Report Update DTO + */ +@Exclude() +export class ReportUpdateDto + extends IntersectionType( + PickType(ReportDto, ['status', 'file'] as const), + PartialType(PickType(ReportDto, ['errorMessage'] as const)), + ) + implements ReportUpdatableInterface {} diff --git a/packages/nestjs-report/src/dto/report.dto.ts b/packages/nestjs-report/src/dto/report.dto.ts new file mode 100644 index 00000000..55684a49 --- /dev/null +++ b/packages/nestjs-report/src/dto/report.dto.ts @@ -0,0 +1,66 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { ReportInterface, ReportStatusEnum } from '@concepta/ts-common'; +import { CommonEntityDto, ReferenceIdDto } from '@concepta/nestjs-common'; + +/** + * Report DTO + */ +@Exclude() +export class ReportDto extends CommonEntityDto implements ReportInterface { + /** + * Storage provider key + */ + @Expose() + @ApiProperty({ + type: 'string', + description: 'serviceKey of the report', + }) + @IsString() + serviceKey = ''; + + @Expose() + @ApiProperty({ + type: 'string', + description: 'Name of the report', + }) + @IsString() + name = ''; + + @Expose() + @ApiProperty({ + type: 'string', + description: 'Error message for when tried to generate report', + }) + @IsString() + errorMessage = ''; + + @Expose() + @ApiProperty({ + enum: ReportStatusEnum, + enumName: 'ReportStatusEnum', + description: 'Status of the report', + }) + @IsEnum(ReportStatusEnum) + status = ReportStatusEnum.Processing; + + @Expose() + @ApiProperty({ + type: 'string', + description: 'Dynamic download URL for the report', + }) + @IsString() + @IsOptional() + downloadUrl = ''; + + @Expose() + @ApiProperty({ + type: ReferenceIdDto, + description: 'The file of the report', + }) + @Type(() => ReferenceIdDto) + @ValidateNested() + file: ReferenceIdInterface = { id: '' }; +} diff --git a/packages/nestjs-report/src/entities/report-postgres.entity.ts b/packages/nestjs-report/src/entities/report-postgres.entity.ts new file mode 100644 index 00000000..9307fc33 --- /dev/null +++ b/packages/nestjs-report/src/entities/report-postgres.entity.ts @@ -0,0 +1,32 @@ +import { Column, Entity, Unique } from 'typeorm'; +import { ReportStatusEnum } from '@concepta/ts-common'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { CommonPostgresEntity } from '@concepta/typeorm-common'; +import { ReportEntityInterface } from '../interfaces/report-entity.interface'; + +/** + * Report Postgres Entity + */ +@Entity() +@Unique(['serviceKey', 'name']) +export class ReportPostgresEntity + extends CommonPostgresEntity + implements ReportEntityInterface +{ + @Column() + serviceKey!: string; + + @Column({ type: 'citext' }) + name!: string; + + @Column({ + type: 'enum', + enum: ReportStatusEnum, + }) + status!: ReportStatusEnum; + + @Column({ type: 'text', nullable: true, default: null }) + errorMessage: string | null = null; + + file!: ReferenceIdInterface; +} diff --git a/packages/nestjs-report/src/entities/report-sqlite.entity.ts b/packages/nestjs-report/src/entities/report-sqlite.entity.ts new file mode 100644 index 00000000..190ad745 --- /dev/null +++ b/packages/nestjs-report/src/entities/report-sqlite.entity.ts @@ -0,0 +1,32 @@ +import { Column, Entity, Unique } from 'typeorm'; +import { ReportStatusEnum } from '@concepta/ts-common'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { CommonSqliteEntity } from '@concepta/typeorm-common'; +import { ReportEntityInterface } from '../interfaces/report-entity.interface'; + +/** + * Report Sqlite Entity + */ +@Entity() +@Unique(['name', 'serviceKey']) +export class ReportSqliteEntity + extends CommonSqliteEntity + implements ReportEntityInterface +{ + @Column() + serviceKey!: string; + + @Column({ collation: 'NOCASE' }) + name!: string; + + @Column({ + type: 'text', + enum: ReportStatusEnum, + }) + status!: ReportStatusEnum; + + @Column({ type: 'text', nullable: true, default: null }) + errorMessage: string | null = null; + + file!: ReferenceIdInterface; +} diff --git a/packages/nestjs-report/src/exceptions/report-create.exception.ts b/packages/nestjs-report/src/exceptions/report-create.exception.ts new file mode 100644 index 00000000..ae522373 --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-create.exception.ts @@ -0,0 +1,15 @@ +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportCreateException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Error while trying to create a report', + ...options, + }); + + this.errorCode = 'REPORT_CREATE_ERROR'; + } +} diff --git a/packages/nestjs-report/src/exceptions/report-download-url-missing.exception.ts b/packages/nestjs-report/src/exceptions/report-download-url-missing.exception.ts new file mode 100644 index 00000000..aa2496ae --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-download-url-missing.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportDownloadUrlMissingException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Error trying to generate signed download url', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'REPORT_DOWNLOAD_URL_ERROR'; + } +} diff --git a/packages/nestjs-report/src/exceptions/report-duplicated.exception.ts b/packages/nestjs-report/src/exceptions/report-duplicated.exception.ts new file mode 100644 index 00000000..656c4888 --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-duplicated.exception.ts @@ -0,0 +1,33 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportDuplicateEntryException extends RuntimeException { + context: RuntimeException['context'] & { + serviceKey: string; + reportName: string; + }; + + constructor( + serviceKey: string, + reportName: string, + options?: RuntimeExceptionOptions, + ) { + super({ + message: 'Duplicate entry detected for service %s with report %s', + httpStatus: HttpStatus.CONFLICT, + messageParams: [serviceKey, reportName], + ...options, + }); + + this.errorCode = 'REPORT_DUPLICATE_ENTRY_ERROR'; + + this.context = { + ...super.context, + serviceKey, + reportName, + }; + } +} diff --git a/packages/nestjs-report/src/exceptions/report-generator-service-not-found.exception.ts b/packages/nestjs-report/src/exceptions/report-generator-service-not-found.exception.ts new file mode 100644 index 00000000..4bdebc39 --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-generator-service-not-found.exception.ts @@ -0,0 +1,27 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportGeneratorServiceNotFoundException extends RuntimeException { + context: RuntimeException['context'] & { + generatorServiceName: string; + }; + + constructor(generatorServiceName: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Report generator service %s was not registered to be used.', + messageParams: [generatorServiceName], + httpStatus: HttpStatus.NOT_FOUND, + ...options, + }); + + this.errorCode = 'REPORT_GENERATOR_SERVICE_NOT_FOUND_ERROR'; + + this.context = { + ...super.context, + generatorServiceName, + }; + } +} diff --git a/packages/nestjs-report/src/exceptions/report-id-missing.exception.ts b/packages/nestjs-report/src/exceptions/report-id-missing.exception.ts new file mode 100644 index 00000000..cc8b0e04 --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-id-missing.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportIdMissingException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Report id is missing.', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'REPORT_ID_MISSING_ERROR'; + } +} diff --git a/packages/nestjs-report/src/exceptions/report-name-missing.exception.ts b/packages/nestjs-report/src/exceptions/report-name-missing.exception.ts new file mode 100644 index 00000000..8b4339ca --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-name-missing.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportnameMissingException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Reportname is missing.', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'REPORTNAME_MISSING_ERROR'; + } +} diff --git a/packages/nestjs-report/src/exceptions/report-query.exception.ts b/packages/nestjs-report/src/exceptions/report-query.exception.ts new file mode 100644 index 00000000..b3d95d1d --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-query.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportQueryException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Error while trying to do a query to report', + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + ...options, + }); + + this.errorCode = 'REPORT_QUERY_ERROR'; + } +} diff --git a/packages/nestjs-report/src/exceptions/report-service-key-missing.exception.ts b/packages/nestjs-report/src/exceptions/report-service-key-missing.exception.ts new file mode 100644 index 00000000..2ccc6c3a --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-service-key-missing.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportServiceKeyMissingException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Service key is missing.', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'SERVICE_KEY_MISSING_ERROR'; + } +} diff --git a/packages/nestjs-report/src/exceptions/report-timeout.exception.ts b/packages/nestjs-report/src/exceptions/report-timeout.exception.ts new file mode 100644 index 00000000..58df1748 --- /dev/null +++ b/packages/nestjs-report/src/exceptions/report-timeout.exception.ts @@ -0,0 +1,17 @@ +import { HttpStatus } from '@nestjs/common'; +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; + +export class ReportTimeoutException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Report generation timed out.', + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR, + ...options, + }); + + this.errorCode = 'REPORT_GENERATION_TIMEOUT'; + } +} diff --git a/packages/nestjs-report/src/index.spec.ts b/packages/nestjs-report/src/index.spec.ts new file mode 100644 index 00000000..1ddd5e09 --- /dev/null +++ b/packages/nestjs-report/src/index.spec.ts @@ -0,0 +1,44 @@ +import { + ReportCreateDto, + ReportDto, + ReportModule, + ReportPostgresEntity, + ReportService, + ReportSqliteEntity, +} from './index'; + +describe('Report Module', () => { + it('should be a function', () => { + expect(ReportModule).toBeInstanceOf(Function); + }); +}); + +describe('Report Postgres Entity', () => { + it('should be a function', () => { + expect(ReportPostgresEntity).toBeInstanceOf(Function); + }); +}); + +describe('Report Sqlite Entity', () => { + it('should be a function', () => { + expect(ReportSqliteEntity).toBeInstanceOf(Function); + }); +}); + +describe('Report Service', () => { + it('should be a function', () => { + expect(ReportService).toBeInstanceOf(Function); + }); +}); + +describe('Report Dto', () => { + it('should be a function', () => { + expect(ReportDto).toBeInstanceOf(Function); + }); +}); + +describe('Report Create Dto', () => { + it('should be a function', () => { + expect(ReportCreateDto).toBeInstanceOf(Function); + }); +}); diff --git a/packages/nestjs-report/src/index.ts b/packages/nestjs-report/src/index.ts new file mode 100644 index 00000000..3c8f3902 --- /dev/null +++ b/packages/nestjs-report/src/index.ts @@ -0,0 +1,16 @@ +export { ReportModule } from './report.module'; +export { ReportEntityInterface } from './interfaces/report-entity.interface'; + +export { ReportServiceInterface } from './interfaces/report-service.interface'; +export { ReportGeneratorServiceInterface } from './interfaces/report-generator-service.interface'; +export { ReportGeneratorResultInterface } from './interfaces/report-generator-result.interface'; + +export { ReportPostgresEntity } from './entities/report-postgres.entity'; +export { ReportSqliteEntity } from './entities/report-sqlite.entity'; + +export { ReportService } from './services/report.service'; + +export { ReportDto } from './dto/report.dto'; +export { ReportCreateDto } from './dto/report-create.dto'; + +export { DoneCallback } from './report.types'; diff --git a/packages/nestjs-report/src/interfaces/report-entities-options.interface.ts b/packages/nestjs-report/src/interfaces/report-entities-options.interface.ts new file mode 100644 index 00000000..ba43cad2 --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-entities-options.interface.ts @@ -0,0 +1,10 @@ +import { TypeOrmExtEntityOptionInterface } from '@concepta/nestjs-typeorm-ext'; + +import { REPORT_MODULE_REPORT_ENTITY_KEY } from '../report.constants'; +import { ReportEntityInterface } from './report-entity.interface'; + +export interface ReportEntitiesOptionsInterface { + entities: { + [REPORT_MODULE_REPORT_ENTITY_KEY]: TypeOrmExtEntityOptionInterface; + }; +} diff --git a/packages/nestjs-report/src/interfaces/report-entity.interface.ts b/packages/nestjs-report/src/interfaces/report-entity.interface.ts new file mode 100644 index 00000000..e9be3a05 --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-entity.interface.ts @@ -0,0 +1,3 @@ +import { ReportInterface } from '@concepta/ts-common'; + +export interface ReportEntityInterface extends ReportInterface {} diff --git a/packages/nestjs-report/src/interfaces/report-generator-result.interface.ts b/packages/nestjs-report/src/interfaces/report-generator-result.interface.ts new file mode 100644 index 00000000..add0263d --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-generator-result.interface.ts @@ -0,0 +1,6 @@ +import { ReportUpdatableInterface } from '@concepta/ts-common/src/report/interfaces/report-updatable.interface'; +import { ReferenceIdInterface } from '@concepta/ts-core'; + +export interface ReportGeneratorResultInterface + extends ReportUpdatableInterface, + ReferenceIdInterface {} diff --git a/packages/nestjs-report/src/interfaces/report-generator-service.interface.ts b/packages/nestjs-report/src/interfaces/report-generator-service.interface.ts new file mode 100644 index 00000000..cb3d65c8 --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-generator-service.interface.ts @@ -0,0 +1,11 @@ +import { ReportCreatableInterface } from '@concepta/ts-common'; +import { ReportGeneratorResultInterface } from './report-generator-result.interface'; + +export interface ReportGeneratorServiceInterface { + KEY: string; + generateTimeout: number; + generate( + report: ReportCreatableInterface, + ): Promise; + getDownloadUrl(report: ReportCreatableInterface): Promise; +} diff --git a/packages/nestjs-report/src/interfaces/report-lookup-service.interface.ts b/packages/nestjs-report/src/interfaces/report-lookup-service.interface.ts new file mode 100644 index 00000000..792f8eca --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-lookup-service.interface.ts @@ -0,0 +1,23 @@ +import { ReportCreatableInterface, ReportInterface } from '@concepta/ts-common'; +import { + LookupIdInterface, + ReferenceId, + ReferenceIdInterface, +} from '@concepta/ts-core'; +import { QueryOptionsInterface } from '@concepta/typeorm-common'; + +export interface ReportLookupServiceInterface + extends LookupIdInterface< + ReferenceId, + ReportInterface, + QueryOptionsInterface + > { + getUniqueReport( + org: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise; + getWithFile( + report: ReferenceIdInterface, + queryOptions?: QueryOptionsInterface, + ): Promise; +} diff --git a/packages/nestjs-report/src/interfaces/report-mutate-service.interface.ts b/packages/nestjs-report/src/interfaces/report-mutate-service.interface.ts new file mode 100644 index 00000000..087c2e01 --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-mutate-service.interface.ts @@ -0,0 +1,15 @@ +import { ReportCreatableInterface } from '@concepta/ts-common'; +import { + CreateOneInterface, + ReferenceIdInterface, + UpdateOneInterface, +} from '@concepta/ts-core'; +import { ReportEntityInterface } from './report-entity.interface'; +import { ReportUpdatableInterface } from '@concepta/ts-common/src/report/interfaces/report-updatable.interface'; + +export interface ReportMutateServiceInterface + extends CreateOneInterface, + UpdateOneInterface< + ReportUpdatableInterface & ReferenceIdInterface, + ReportEntityInterface + > {} diff --git a/packages/nestjs-report/src/interfaces/report-options-extras.interface.ts b/packages/nestjs-report/src/interfaces/report-options-extras.interface.ts new file mode 100644 index 00000000..08a55dee --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-options-extras.interface.ts @@ -0,0 +1,6 @@ +import { DynamicModule } from '@nestjs/common'; +import { ReportEntitiesOptionsInterface } from './report-entities-options.interface'; + +export interface ReportOptionsExtrasInterface + extends Pick, + Partial {} diff --git a/packages/nestjs-report/src/interfaces/report-options.interface.ts b/packages/nestjs-report/src/interfaces/report-options.interface.ts new file mode 100644 index 00000000..0a34b9d7 --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-options.interface.ts @@ -0,0 +1,7 @@ +import { ReportSettingsInterface } from './report-settings.interface'; +import { ReportGeneratorServiceInterface } from './report-generator-service.interface'; + +export interface ReportOptionsInterface { + reportGeneratorServices?: ReportGeneratorServiceInterface[]; + settings?: ReportSettingsInterface; +} diff --git a/packages/nestjs-report/src/interfaces/report-service.interface.ts b/packages/nestjs-report/src/interfaces/report-service.interface.ts new file mode 100644 index 00000000..16ae0d0e --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-service.interface.ts @@ -0,0 +1,9 @@ +import { DoneCallback } from '../report.types'; +import { ReportCreateDto } from '../dto/report-create.dto'; +import { ReportInterface } from '@concepta/ts-common'; + +export interface ReportServiceInterface { + generate(report: ReportCreateDto): Promise; + fetch(report: Pick): Promise; + done: DoneCallback; +} diff --git a/packages/nestjs-report/src/interfaces/report-settings.interface.ts b/packages/nestjs-report/src/interfaces/report-settings.interface.ts new file mode 100644 index 00000000..f4791d39 --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-settings.interface.ts @@ -0,0 +1,6 @@ +/** + * This interface is a placeholder for future settings + */ +export interface ReportSettingsInterface { + generateTimeout: number; +} diff --git a/packages/nestjs-report/src/interfaces/report-status.interface.ts b/packages/nestjs-report/src/interfaces/report-status.interface.ts new file mode 100644 index 00000000..be9cc26f --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-status.interface.ts @@ -0,0 +1,4 @@ +import { ReportInterface } from '@concepta/ts-common'; + +export interface ReportStatusInterface + extends Pick {} diff --git a/packages/nestjs-report/src/interfaces/report-strategy-service.interface.ts b/packages/nestjs-report/src/interfaces/report-strategy-service.interface.ts new file mode 100644 index 00000000..d4e32843 --- /dev/null +++ b/packages/nestjs-report/src/interfaces/report-strategy-service.interface.ts @@ -0,0 +1,9 @@ +import { ReportCreatableInterface } from '@concepta/ts-common'; +import { ReportGeneratorResultInterface } from './report-generator-result.interface'; + +export interface ReportStrategyServiceInterface { + generate( + report: ReportCreatableInterface, + ): Promise; + getDownloadUrl(report: ReportCreatableInterface): Promise; +} diff --git a/packages/nestjs-report/src/report.constants.ts b/packages/nestjs-report/src/report.constants.ts new file mode 100644 index 00000000..330ab34b --- /dev/null +++ b/packages/nestjs-report/src/report.constants.ts @@ -0,0 +1,8 @@ +export const REPORT_MODULE_SETTINGS_TOKEN = 'REPORT_MODULE_SETTINGS_TOKEN'; + +export const REPORT_MODULE_DEFAULT_SETTINGS_TOKEN = + 'REPORT_MODULE_DEFAULT_SETTINGS_TOKEN'; + +export const REPORT_MODULE_REPORT_ENTITY_KEY = 'report'; + +export const REPORT_STRATEGY_SERVICE_KEY = 'REPORT_STRATEGY_SERVICE_KEY'; diff --git a/packages/nestjs-report/src/report.module-definition.ts b/packages/nestjs-report/src/report.module-definition.ts new file mode 100644 index 00000000..e00fff2f --- /dev/null +++ b/packages/nestjs-report/src/report.module-definition.ts @@ -0,0 +1,136 @@ +import { + ConfigurableModuleBuilder, + DynamicModule, + Provider, +} from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { createSettingsProvider } from '@concepta/nestjs-common'; + +import { + REPORT_MODULE_SETTINGS_TOKEN, + REPORT_STRATEGY_SERVICE_KEY, +} from './report.constants'; + +import { ReportOptionsInterface } from './interfaces/report-options.interface'; +import { ReportEntitiesOptionsInterface } from './interfaces/report-entities-options.interface'; +import { ReportOptionsExtrasInterface } from './interfaces/report-options-extras.interface'; +import { ReportSettingsInterface } from './interfaces/report-settings.interface'; +import { ReportService } from './services/report.service'; +import { ReportStrategyService } from './services/report-strategy.service'; + +import { reportDefaultConfig } from './config/report-default.config'; +import { ReportMutateService } from './services/report-mutate.service'; +import { ReportLookupService } from './services/report-lookup.service'; + +const RAW_OPTIONS_TOKEN = Symbol('__REPORT_MODULE_RAW_OPTIONS_TOKEN__'); + +export const { + ConfigurableModuleClass: ReportModuleClass, + OPTIONS_TYPE: REPORT_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: REPORT_ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'Report', + optionsInjectionToken: RAW_OPTIONS_TOKEN, +}) + .setExtras( + { global: false }, + definitionTransform, + ) + .build(); + +export type ReportOptions = Omit; + +export type ReportAsyncOptions = Omit< + typeof REPORT_ASYNC_OPTIONS_TYPE, + 'global' +>; + +function definitionTransform( + definition: DynamicModule, + extras: ReportOptionsExtrasInterface, +): DynamicModule { + const { providers = [], imports = [] } = definition; + const { global = false, entities } = extras; + + if (!entities) { + throw new Error('You must provide the entities option'); + } + + return { + ...definition, + global, + imports: createReportImports({ imports, entities }), + providers: createReportProviders({ providers }), + exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createReportExports()], + }; +} + +export function createReportImports( + options: Pick & ReportEntitiesOptionsInterface, +): DynamicModule['imports'] { + return [ + ...(options.imports ?? []), + ConfigModule.forFeature(reportDefaultConfig), + TypeOrmExtModule.forFeature(options.entities), + ]; +} + +export function createReportExports(): Required< + Pick +>['exports'] { + return [REPORT_MODULE_SETTINGS_TOKEN, ReportService]; +} + +export function createReportProviders(options: { + overrides?: ReportOptions; + providers?: Provider[]; +}): Provider[] { + return [ + ...(options.providers ?? []), + createReportSettingsProvider(options.overrides), + createStrategyServiceProvider(options.overrides), + ReportMutateService, + ReportLookupService, + ReportStrategyService, + ReportService, + ]; +} + +export function createReportSettingsProvider( + optionsOverrides?: ReportOptions, +): Provider { + return createSettingsProvider< + ReportSettingsInterface, + ReportOptionsInterface + >({ + settingsToken: REPORT_MODULE_SETTINGS_TOKEN, + optionsToken: RAW_OPTIONS_TOKEN, + settingsKey: reportDefaultConfig.KEY, + optionsOverrides, + }); +} + +export function createStrategyServiceProvider( + optionsOverrides?: ReportOptions, +): Provider { + return { + provide: REPORT_STRATEGY_SERVICE_KEY, + inject: [RAW_OPTIONS_TOKEN, ReportStrategyService], + useFactory: async ( + options: ReportOptionsInterface, + reportStrategyService: ReportStrategyService, + ) => { + const storageServices = + optionsOverrides?.reportGeneratorServices ?? + options.reportGeneratorServices; + + storageServices?.forEach((storageService) => { + reportStrategyService.addStorageService(storageService); + }); + + return reportStrategyService; + }, + }; +} diff --git a/packages/nestjs-report/src/report.module.spec.ts b/packages/nestjs-report/src/report.module.spec.ts new file mode 100644 index 00000000..23f4245d --- /dev/null +++ b/packages/nestjs-report/src/report.module.spec.ts @@ -0,0 +1,227 @@ +import { Repository } from 'typeorm'; +import { DynamicModule, ModuleMetadata } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ReportStatusEnum } from '@concepta/ts-common'; +import { FileModule } from '@concepta/nestjs-file'; +import { + getDynamicRepositoryToken, + getEntityRepositoryToken, + TypeOrmExtModule, +} from '@concepta/nestjs-typeorm-ext'; + +import { ReportService } from './services/report.service'; + +import { REPORT_MODULE_REPORT_ENTITY_KEY } from './report.constants'; + +import { ReportEntityInterface } from './interfaces/report-entity.interface'; + +import { AwsStorageService } from './__fixtures__/aws-storage.service'; +import { + REPORT_KEY_FIXTURE, + REPORT_NAME_FIXTURE, + REPORT_SHORT_DELAY_KEY_FIXTURE, +} from './__fixtures__/constants.fixture'; +import { FileEntityFixture } from './__fixtures__/file/file-entity.fixture'; +import { MyReportGeneratorShortDelayService } from './__fixtures__/my-report-generator-short-delay.service'; +import { MyReportGeneratorService } from './__fixtures__/my-report-generator.service'; +import { ReportGeneratorModuleFixture } from './__fixtures__/report-generator.module.fixture'; +import { ReportEntityFixture } from './__fixtures__/report/report-entity.fixture'; +import { UserEntityFixture } from './__fixtures__/user/entities/user.entity.fixture'; +import { ReportModule } from './report.module'; +import { delay } from './utils/delay.util'; + +describe(ReportModule, () => { + let testModule: TestingModule; + let reportModule: ReportModule; + let reportService: ReportService; + let reportEntityRepo: Repository; + let reportDynamicRepo: Repository; + + describe(ReportModule.forRootAsync, () => { + beforeEach(async () => { + testModule = await Test.createTestingModule( + testModuleFactory([ + ReportModule.forRootAsync({ + inject: [ + MyReportGeneratorService, + MyReportGeneratorShortDelayService, + ], + useFactory: ( + myGeneratorService: MyReportGeneratorService, + myGeneratorWithDelay: MyReportGeneratorService, + ) => ({ + reportGeneratorServices: [ + myGeneratorService, + myGeneratorWithDelay, + ], + }), + entities: { + report: { + entity: ReportEntityFixture, + }, + }, + }), + ]), + ).compile(); + commonVars(); + }); + + afterAll(async () => { + await delay(1000); + testModule.close(); + }); + + it('module should be loaded', async () => { + commonTests(); + }); + + /** + * generate report with success + */ + const generateReport = async () => { + const result = await reportService.generate({ + name: REPORT_NAME_FIXTURE, + serviceKey: REPORT_KEY_FIXTURE, + // TODO: check if proceesing should be defined here or automatically + status: ReportStatusEnum.Processing, + }); + return result; + }; + + /** + * generate report that will throw timeout exception + */ + const generateReportWithTimeout = async () => { + const result = await reportService.generate({ + name: REPORT_NAME_FIXTURE, + serviceKey: REPORT_SHORT_DELAY_KEY_FIXTURE, + status: ReportStatusEnum.Processing, + }); + return result; + }; + + const fetchReport = async (id: string) => { + const report = await reportService.fetch({ + id: id, + }); + return report; + }; + + it('generate with success ', async () => { + const result = await generateReport(); + expect(result.serviceKey).toBe(REPORT_KEY_FIXTURE); + expect(result.name).toBe(REPORT_NAME_FIXTURE); + + // add a delay to ensure generator will complete + await delay(2000); + const report = await fetchReport(result.id); + + expect(report.id).toBe(result.id); + expect(report.status).toBe(ReportStatusEnum.Complete); + }); + + it('generate with processing ', async () => { + const result = await generateReport(); + expect(result.serviceKey).toBe(REPORT_KEY_FIXTURE); + expect(result.name).toBe(REPORT_NAME_FIXTURE); + + // since there is no delay status will still be processing + const report = await fetchReport(result.id); + expect(report.id).toBe(result.id); + expect(report.status).toBe(ReportStatusEnum.Processing); + }); + it('generator with timeout', async () => { + const result = await generateReportWithTimeout(); + expect(result.serviceKey).toBe(REPORT_SHORT_DELAY_KEY_FIXTURE); + expect(result.name).toBe(REPORT_NAME_FIXTURE); + + // add a delay to get wait for timeout error + await delay(200); + + const report = await fetchReport(result.id); + expect(report.id).toBe(result.id); + expect(report.status).toBe(ReportStatusEnum.Error); + }); + }); + + describe(ReportModule.registerAsync, () => { + beforeEach(async () => { + testModule = await Test.createTestingModule( + testModuleFactory([ + ReportModule.registerAsync({ + inject: [MyReportGeneratorService], + useFactory: (awsStorageService: MyReportGeneratorService) => ({ + reportGeneratorServices: [awsStorageService], + }), + entities: { + report: { + entity: ReportEntityFixture, + }, + }, + }), + ]), + ).compile(); + }); + it('module should be loaded', async () => { + commonVars(); + commonTests(); + }); + }); + + const commonVars = () => { + reportModule = testModule.get(ReportModule); + reportService = testModule.get(ReportService); + reportEntityRepo = testModule.get>( + getEntityRepositoryToken(REPORT_MODULE_REPORT_ENTITY_KEY), + ); + reportDynamicRepo = testModule.get( + getDynamicRepositoryToken(REPORT_MODULE_REPORT_ENTITY_KEY), + ); + }; + + const commonTests = async () => { + expect(reportModule).toBeInstanceOf(ReportModule); + expect(reportService).toBeInstanceOf(ReportService); + expect(reportEntityRepo).toBeInstanceOf(Repository); + expect(reportDynamicRepo).toBeInstanceOf(Repository); + + const result = await reportService.generate({ + name: REPORT_NAME_FIXTURE, + serviceKey: REPORT_KEY_FIXTURE, + status: ReportStatusEnum.Processing, + }); + expect(result.serviceKey).toBe(REPORT_KEY_FIXTURE); + expect(result.name).toBe(REPORT_NAME_FIXTURE); + + const report = await reportService.fetch({ + id: result.id, + }); + expect(report.id).toBe(report.id); + expect(report.status).toBe(ReportStatusEnum.Processing); + }; +}); + +function testModuleFactory( + extraImports: DynamicModule['imports'] = [], +): ModuleMetadata { + return { + imports: [ + TypeOrmExtModule.forRoot({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [UserEntityFixture, FileEntityFixture, ReportEntityFixture], + }), + FileModule.forRoot({ + entities: { + file: { + entity: FileEntityFixture, + }, + }, + storageServices: [new AwsStorageService()], + }), + ReportGeneratorModuleFixture, + ...extraImports, + ], + }; +} diff --git a/packages/nestjs-report/src/report.module.ts b/packages/nestjs-report/src/report.module.ts new file mode 100644 index 00000000..78b35b92 --- /dev/null +++ b/packages/nestjs-report/src/report.module.ts @@ -0,0 +1,26 @@ +import { DynamicModule, Module } from '@nestjs/common'; + +import { + ReportAsyncOptions, + ReportModuleClass, + ReportOptions, +} from './report.module-definition'; + +@Module({}) +export class ReportModule extends ReportModuleClass { + static register(options: ReportOptions): DynamicModule { + return super.register(options); + } + + static registerAsync(options: ReportAsyncOptions): DynamicModule { + return super.registerAsync(options); + } + + static forRoot(options: ReportOptions): DynamicModule { + return super.register({ ...options, global: true }); + } + + static forRootAsync(options: ReportAsyncOptions): DynamicModule { + return super.registerAsync({ ...options, global: true }); + } +} diff --git a/packages/nestjs-report/src/report.types.ts b/packages/nestjs-report/src/report.types.ts new file mode 100644 index 00000000..885fb327 --- /dev/null +++ b/packages/nestjs-report/src/report.types.ts @@ -0,0 +1,5 @@ +import { ReportGeneratorResultInterface } from './interfaces/report-generator-result.interface'; + +export type DoneCallback = ( + report: ReportGeneratorResultInterface, +) => Promise; diff --git a/packages/nestjs-report/src/services/report-lookup.service.ts b/packages/nestjs-report/src/services/report-lookup.service.ts new file mode 100644 index 00000000..39dabeb5 --- /dev/null +++ b/packages/nestjs-report/src/services/report-lookup.service.ts @@ -0,0 +1,68 @@ +import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; +import { LookupService, QueryOptionsInterface } from '@concepta/typeorm-common'; +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; + +import { REPORT_MODULE_REPORT_ENTITY_KEY } from '../report.constants'; +import { ReportEntityInterface } from '../interfaces/report-entity.interface'; +import { ReportLookupServiceInterface } from '../interfaces/report-lookup-service.interface'; +import { ReportInterface } from '@concepta/ts-common'; +import { ReportServiceKeyMissingException } from '../exceptions/report-service-key-missing.exception'; +import { ReportnameMissingException } from '../exceptions/report-name-missing.exception'; +import { ReferenceIdInterface } from '@concepta/ts-core'; +import { ReportQueryException } from '../exceptions/report-query.exception'; + +/** + * Report lookup service + */ +@Injectable() +export class ReportLookupService + extends LookupService + implements ReportLookupServiceInterface +{ + constructor( + @InjectDynamicRepository(REPORT_MODULE_REPORT_ENTITY_KEY) + repo: Repository, + ) { + super(repo); + } + async getUniqueReport( + report: Pick, + queryOptions?: QueryOptionsInterface, + ) { + if (!report.serviceKey) { + throw new ReportServiceKeyMissingException(); + } + if (!report.name) { + throw new ReportnameMissingException(); + } + return this.findOne( + { + where: { + serviceKey: report.serviceKey, + name: report.name, + }, + }, + queryOptions, + ); + } + + async getWithFile( + report: ReferenceIdInterface, + queryOptions?: QueryOptionsInterface, + ) { + try { + return this.findOne( + { + where: { + id: report.id, + }, + relations: ['file'], + }, + queryOptions, + ); + } catch (originalError) { + throw new ReportQueryException({ originalError }); + } + } +} diff --git a/packages/nestjs-report/src/services/report-mutate.service.ts b/packages/nestjs-report/src/services/report-mutate.service.ts new file mode 100644 index 00000000..bf22ae65 --- /dev/null +++ b/packages/nestjs-report/src/services/report-mutate.service.ts @@ -0,0 +1,40 @@ +import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; +import { ReportCreatableInterface } from '@concepta/ts-common'; +import { MutateService } from '@concepta/typeorm-common'; +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { ReportEntityInterface } from '../interfaces/report-entity.interface'; + +import { ReportUpdatableInterface } from '@concepta/ts-common/src/report/interfaces/report-updatable.interface'; +import { ReportCreateDto } from '../dto/report-create.dto'; +import { ReportUpdateDto } from '../dto/report-update.dto'; +import { ReportMutateServiceInterface } from '../interfaces/report-mutate-service.interface'; +import { REPORT_MODULE_REPORT_ENTITY_KEY } from '../report.constants'; + +/** + * Report mutate service + */ +@Injectable() +export class ReportMutateService + extends MutateService< + ReportEntityInterface, + ReportCreatableInterface, + ReportUpdatableInterface + > + implements ReportMutateServiceInterface +{ + protected createDto = ReportCreateDto; + protected updateDto = ReportUpdateDto; + + /** + * Constructor + * + * @param repo - instance of the report repo + */ + constructor( + @InjectDynamicRepository(REPORT_MODULE_REPORT_ENTITY_KEY) + repo: Repository, + ) { + super(repo); + } +} diff --git a/packages/nestjs-report/src/services/report-strategy.service.spec.ts b/packages/nestjs-report/src/services/report-strategy.service.spec.ts new file mode 100644 index 00000000..3b372dfe --- /dev/null +++ b/packages/nestjs-report/src/services/report-strategy.service.spec.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'crypto'; +import { mock } from 'jest-mock-extended'; +import { + ReportCreatableInterface, + ReportInterface, + ReportStatusEnum, +} from '@concepta/ts-common'; +import { ReportCreateDto } from '../dto/report-create.dto'; +import { ReportGeneratorServiceInterface } from '../interfaces/report-generator-service.interface'; +import { ReportStrategyService } from './report-strategy.service'; + +class MockStorageService implements ReportGeneratorServiceInterface { + KEY = 'mock-service'; + + generateTimeout = 3600; + + uploadTimeout = 3600; + + generate(_report: ReportCreatableInterface): Promise { + const mockReport: ReportInterface = { + id: randomUUID(), + serviceKey: 'test-service', + name: 'test.txt', + status: ReportStatusEnum.Complete, + errorMessage: null, + downloadUrl: 'https://download.url', + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: new Date(), + version: 1, + }; + return Promise.resolve(mockReport); + } + + getUploadUrl(report: ReportCreateDto): string { + return `http://upload.url/${report.serviceKey}/${report.name}`; + } + + getDownloadUrl(report: ReportCreateDto): Promise { + return Promise.resolve( + `http://download.url/${report.serviceKey}/${report.name}`, + ); + } +} + +describe(ReportStrategyService.name, () => { + let reportStrategyService: ReportStrategyService; + let mockStorageService: ReportGeneratorServiceInterface; + + beforeEach(() => { + reportStrategyService = new ReportStrategyService({ + generateTimeout: 3600 * 1000, + }); + mockStorageService = new MockStorageService(); + }); + + describe(ReportStrategyService.prototype.addStorageService.name, () => { + it('should add a storage service', () => { + reportStrategyService.addStorageService(mockStorageService); + expect(reportStrategyService['reportGeneratorServices']).toContain( + mockStorageService, + ); + }); + }); + + describe(ReportStrategyService.prototype.generate.name, () => { + it('should return the upload URL from the correct storage service', async () => { + const mockReport: ReportInterface = mock({ + serviceKey: 'mock-service', + name: 'test.jpg', + }); + mockReport.serviceKey = 'mock-service'; + mockReport.name = 'test.jpg'; + reportStrategyService.addStorageService(mockStorageService); + + jest.spyOn(mockStorageService, 'generate'); + + const result = await reportStrategyService.generate(mockReport); + + expect(mockStorageService.generate).toBeCalledWith(mockReport); + expect(result.status).toBe(ReportStatusEnum.Complete); + }); + }); + + describe(ReportStrategyService.prototype.getDownloadUrl.name, () => { + it('should return the download URL from the correct storage service', async () => { + const mockReport = new ReportCreateDto(); + mockReport.serviceKey = 'mock-service'; + mockReport.name = 'test.jpg'; + reportStrategyService.addStorageService(mockStorageService); + + const result = await reportStrategyService.getDownloadUrl(mockReport); + + expect(result).toBe('http://download.url/mock-service/test.jpg'); + }); + }); + + describe( + ReportStrategyService.prototype['resolveGeneratorService'].name, + () => { + it('should return the correct storage service for a given report', async () => { + const mockReport = new ReportCreateDto(); + mockReport.serviceKey = 'mock-service'; + reportStrategyService.addStorageService(mockStorageService); + + const result = await reportStrategyService['resolveGeneratorService']( + mockReport, + ); + + expect(result).toBe(mockStorageService); + }); + }, + ); +}); diff --git a/packages/nestjs-report/src/services/report-strategy.service.ts b/packages/nestjs-report/src/services/report-strategy.service.ts new file mode 100644 index 00000000..616a4214 --- /dev/null +++ b/packages/nestjs-report/src/services/report-strategy.service.ts @@ -0,0 +1,71 @@ +import { Inject } from '@nestjs/common'; +import { ReportCreatableInterface } from '@concepta/ts-common'; +import { mapNonErrorToException } from '@concepta/ts-core'; +import { ReportGeneratorServiceNotFoundException } from '../exceptions/report-generator-service-not-found.exception'; +import { ReportTimeoutException } from '../exceptions/report-timeout.exception'; +import { ReportGeneratorResultInterface } from '../interfaces/report-generator-result.interface'; +import { ReportGeneratorServiceInterface } from '../interfaces/report-generator-service.interface'; +import { ReportSettingsInterface } from '../interfaces/report-settings.interface'; +import { ReportStrategyServiceInterface } from '../interfaces/report-strategy-service.interface'; +import { REPORT_MODULE_SETTINGS_TOKEN } from '../report.constants'; + +export class ReportStrategyService implements ReportStrategyServiceInterface { + private readonly reportGeneratorServices: ReportGeneratorServiceInterface[] = + []; + constructor( + @Inject(REPORT_MODULE_SETTINGS_TOKEN) + private readonly settings: ReportSettingsInterface, + ) {} + + public addStorageService( + reportGeneratorService: ReportGeneratorServiceInterface, + ): void { + this.reportGeneratorServices.push(reportGeneratorService); + } + + async generate( + report: ReportCreatableInterface, + ): Promise { + try { + const generatorService = this.resolveGeneratorService(report); + const timeoutMs = + generatorService.generateTimeout || this.settings.generateTimeout; + + return await Promise.race([ + generatorService.generate(report), + this.createTimeout(timeoutMs), + ]); + } catch (error) { + if (error instanceof ReportTimeoutException) { + throw error; + } + throw mapNonErrorToException(error); + } + } + + async getDownloadUrl(report: ReportCreatableInterface): Promise { + return this.resolveGeneratorService(report).getDownloadUrl(report); + } + + protected resolveGeneratorService( + report: ReportCreatableInterface, + ): ReportGeneratorServiceInterface { + const generatorService = this.reportGeneratorServices.find( + (storageService) => { + return storageService.KEY === report.serviceKey; + }, + ); + + if (generatorService) { + return generatorService; + } + + throw new ReportGeneratorServiceNotFoundException(report.serviceKey); + } + + protected createTimeout(timeoutMs: number): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(new ReportTimeoutException()), timeoutMs); + }); + } +} diff --git a/packages/nestjs-report/src/services/report.service.spec.ts b/packages/nestjs-report/src/services/report.service.spec.ts new file mode 100644 index 00000000..7cd5b4ba --- /dev/null +++ b/packages/nestjs-report/src/services/report.service.spec.ts @@ -0,0 +1,196 @@ +import { randomUUID } from 'crypto'; +import { mock } from 'jest-mock-extended'; +import { Repository } from 'typeorm'; +import { + ReportCreatableInterface, + ReportStatusEnum, +} from '@concepta/ts-common'; +import { ReportCreateDto } from '../dto/report-create.dto'; +import { ReportDuplicateEntryException } from '../exceptions/report-duplicated.exception'; +import { ReportQueryException } from '../exceptions/report-query.exception'; +import { ReportEntityInterface } from '../interfaces/report-entity.interface'; +import { ReportStrategyService } from './report-strategy.service'; +import { ReportService } from './report.service'; +import { ReportMutateService } from './report-mutate.service'; +import { ReportLookupService } from './report-lookup.service'; + +const mockReport: ReportEntityInterface = { + id: randomUUID(), + serviceKey: 'test-service', + name: 'test.txt', + status: ReportStatusEnum.Complete, + errorMessage: null, + downloadUrl: 'https://download.url', + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: new Date(), + version: 1, +}; + +const mockReportCreateDto: ReportCreateDto = { + serviceKey: mockReport.serviceKey, + name: mockReport.name, + status: ReportStatusEnum.Processing, +}; + +describe(ReportService.name, () => { + let reportService: ReportService; + let reportRepo: jest.Mocked>; + let reportStrategyService: jest.Mocked; + let reportMutateService: ReportMutateService; + let reportLookupService: ReportLookupService; + + beforeEach(() => { + reportRepo = createMockRepository(); + reportStrategyService = createMockReportStrategyService(); + reportMutateService = new ReportMutateService(reportRepo); + reportLookupService = new ReportLookupService(reportRepo); + + reportService = new ReportService( + reportStrategyService, + reportMutateService, + reportLookupService, + ); + reportRepo.create.mockReturnValue(mockReport); + const mockTransactionalEntityManager = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockReturnValue(mockReport), + save: jest.fn().mockResolvedValue(mockReport), + }; + + reportRepo.manager.transaction = jest + .fn() + .mockImplementation(async (cb) => { + return await cb(mockTransactionalEntityManager); + }); + }); + + describe('generate', () => { + it('should create a new report', async () => { + reportStrategyService.generate.mockImplementationOnce( + (_report: ReportCreatableInterface) => { + return Promise.resolve(mockReport); + }, + ); + jest + .spyOn(reportRepo, 'findOne') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockReport); + jest.spyOn(reportService, 'done'); + + const result = await reportService.generate(mockReportCreateDto); + expect(reportStrategyService.generate).toHaveBeenCalledWith(mockReport); + expect(reportService.done).toHaveBeenCalledWith(mockReport); + expect(result.id).toBe(mockReport.id); + }); + it('should report be with error', async () => { + reportStrategyService.generate.mockImplementationOnce( + (_report: ReportCreatableInterface) => { + throw new Error('Error generating report'); + }, + ); + jest + .spyOn(reportRepo, 'findOne') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockReport); + const doneSpy = jest.spyOn(reportService, 'done'); + + const result = await reportService.generate(mockReportCreateDto); + expect(reportStrategyService.generate).toHaveBeenCalledWith(mockReport); + expect(doneSpy).toHaveBeenCalledWith({ + id: mockReport.id, + status: ReportStatusEnum.Error, + errorMessage: 'Error generating report', + }); + expect(result.id).toBe(mockReport.id); + }); + it('should throw excetion', async () => { + reportStrategyService.generate.mockImplementationOnce( + (_report: ReportCreatableInterface) => { + return Promise.resolve(mockReport); + }, + ); + jest.spyOn(reportRepo, 'findOne').mockResolvedValueOnce(mockReport); + jest.spyOn(reportService, 'done'); + + await expect(reportService.generate(mockReportCreateDto)).rejects.toThrow( + ReportDuplicateEntryException, + ); + }); + }); + + describe('fetch', () => { + it('should return report', async () => { + reportRepo.findOne.mockResolvedValue(mockReport); + reportStrategyService.getDownloadUrl.mockImplementationOnce( + (_report: ReportCreatableInterface) => { + return Promise.resolve(mockReport.downloadUrl || ''); + }, + ); + jest.spyOn(reportRepo, 'findOne').mockResolvedValueOnce(mockReport); + + const result = await reportService.fetch({ id: mockReport.id }); + + expect(reportRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockReport.id }, + relations: ['file'], + }); + expect(result.downloadUrl).toBe(mockReport.downloadUrl); + }); + + it('should return download URL for existing report', async () => { + const reportWithFile = { + ...mockReport, + file: { + id: randomUUID(), + }, + }; + reportRepo.findOne.mockResolvedValue(reportWithFile); + reportStrategyService.getDownloadUrl.mockImplementationOnce( + (_report: ReportCreatableInterface) => { + return Promise.resolve(reportWithFile.downloadUrl || ''); + }, + ); + jest.spyOn(reportRepo, 'findOne').mockResolvedValueOnce(reportWithFile); + + const result = await reportService.fetch({ id: mockReport.id }); + + expect(reportRepo.findOne).toHaveBeenCalledWith({ + where: { id: reportWithFile.id }, + relations: ['file'], + }); + expect(reportStrategyService.getDownloadUrl).toHaveBeenCalledWith( + reportWithFile, + ); + expect(result.downloadUrl).toBe(reportWithFile.downloadUrl); + }); + + it('should throw ReportQueryException if report not found', async () => { + reportRepo.findOne.mockResolvedValue(null); + + await expect(reportService.fetch({ id: mockReport.id })).rejects.toThrow( + ReportQueryException, + ); + expect(reportRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockReport.id }, + relations: ['file'], + }); + }); + }); +}); + +function createMockRepository(): jest.Mocked< + Repository +> { + return mock>({ + findOne: jest.fn().mockResolvedValue(mockReport), + create: jest.fn().mockReturnValue(mockReport), + save: jest.fn().mockResolvedValue(mockReport), + }); +} + +function createMockReportStrategyService(): jest.Mocked { + return mock({ + generate: jest.fn().mockResolvedValue(mockReport), + }); +} diff --git a/packages/nestjs-report/src/services/report.service.ts b/packages/nestjs-report/src/services/report.service.ts new file mode 100644 index 00000000..641914d5 --- /dev/null +++ b/packages/nestjs-report/src/services/report.service.ts @@ -0,0 +1,125 @@ +import { + ReportCreatableInterface, + ReportInterface, + ReportStatusEnum, +} from '@concepta/ts-common'; +import { mapNonErrorToException } from '@concepta/ts-core'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; + +import { ReportCreateException } from '../exceptions/report-create.exception'; +import { ReportDuplicateEntryException } from '../exceptions/report-duplicated.exception'; +import { ReportIdMissingException } from '../exceptions/report-id-missing.exception'; +import { ReportQueryException } from '../exceptions/report-query.exception'; +import { ReportEntityInterface } from '../interfaces/report-entity.interface'; +import { ReportGeneratorResultInterface } from '../interfaces/report-generator-result.interface'; +import { ReportLookupServiceInterface } from '../interfaces/report-lookup-service.interface'; +import { ReportMutateServiceInterface } from '../interfaces/report-mutate-service.interface'; +import { ReportServiceInterface } from '../interfaces/report-service.interface'; +import { REPORT_STRATEGY_SERVICE_KEY } from '../report.constants'; +import { ReportLookupService } from './report-lookup.service'; +import { ReportMutateService } from './report-mutate.service'; +import { ReportStrategyService } from './report-strategy.service'; + +/** + * Service responsible for managing report operations. + */ +@Injectable() +export class ReportService implements ReportServiceInterface { + constructor( + @Inject(REPORT_STRATEGY_SERVICE_KEY) + private reportStrategyService: ReportStrategyService, + @Inject(ReportMutateService) + private reportMutateService: ReportMutateServiceInterface, + @Inject(ReportLookupService) + private reportLookupService: ReportLookupServiceInterface, + ) {} + + async generate(report: ReportCreatableInterface): Promise { + await this.checkExistingReport(report); + try { + const reportDb = await this.createAndSaveReport(report); + + // trigger report generation + this.generateAndProcessReport(reportDb); + + return reportDb; + } catch (originalError) { + throw new ReportCreateException({ originalError }); + } + } + + async fetch(report: Pick): Promise { + if (!report.id) { + throw new ReportIdMissingException(); + } + + const dbReport = await this.reportLookupService.getWithFile(report); + + if (!dbReport) { + throw new ReportQueryException({ + message: 'Report with id %s not found', + messageParams: [report.id], + httpStatus: HttpStatus.NOT_FOUND, + }); + } + + return this.addDownloadUrl(dbReport); + } + + async done(report: ReportGeneratorResultInterface): Promise { + if (!report.id) { + throw new ReportIdMissingException(); + } + + this.reportMutateService.update(report); + } + + protected async generateAndProcessReport( + reportDb: ReportInterface, + ): Promise { + try { + const result = await this.reportStrategyService.generate(reportDb); + await this.done(result); + } catch (err) { + const finalError = mapNonErrorToException(err); + await this.done({ + id: reportDb.id, + status: ReportStatusEnum.Error, + errorMessage: finalError.message, + }); + } + } + + protected async createAndSaveReport( + report: ReportCreatableInterface, + ): Promise { + return await this.reportMutateService.create(report); + } + + protected async checkExistingReport( + report: ReportCreatableInterface, + ): Promise { + const existingReport = await this.reportLookupService.getUniqueReport( + report, + ); + + if (existingReport) { + throw new ReportDuplicateEntryException(report.serviceKey, report.name); + } + } + + protected async addDownloadUrl( + report: ReportInterface, + ): Promise { + if (report.file?.id) { + try { + report.downloadUrl = await this.reportStrategyService.getDownloadUrl( + report, + ); + } catch (err) { + report.downloadUrl = ''; + } + } + return report; + } +} diff --git a/packages/nestjs-report/src/utils/delay.util.ts b/packages/nestjs-report/src/utils/delay.util.ts new file mode 100644 index 00000000..59216636 --- /dev/null +++ b/packages/nestjs-report/src/utils/delay.util.ts @@ -0,0 +1,2 @@ +export const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/nestjs-report/tsconfig.json b/packages/nestjs-report/tsconfig.json new file mode 100644 index 00000000..b7d14c2a --- /dev/null +++ b/packages/nestjs-report/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types" + ] + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../nestjs-file" + } + ] +} diff --git a/packages/nestjs-report/typedoc.json b/packages/nestjs-report/typedoc.json new file mode 100644 index 00000000..944fda5a --- /dev/null +++ b/packages/nestjs-report/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +} \ No newline at end of file diff --git a/packages/ts-common/src/index.ts b/packages/ts-common/src/index.ts index 1ad80548..c68ad1bb 100644 --- a/packages/ts-common/src/index.ts +++ b/packages/ts-common/src/index.ts @@ -59,6 +59,11 @@ export { InvitationGetUserEventResponseInterface } from './invitation/interfaces export { FileInterface } from './file/interfaces/file.interface'; export { FileCreatableInterface } from './file/interfaces/file-creatable.interface'; +export { ReportInterface } from './report/interfaces/report.interface'; +export { ReportCreatableInterface } from './report/interfaces/report-creatable.interface'; +export { ReportUpdatableInterface } from './report/interfaces/report-updatable.interface'; +export { ReportStatusEnum } from './report/enum/report-status.enum'; + export { INVITATION_MODULE_CATEGORY_USER_KEY, INVITATION_MODULE_CATEGORY_ORG_KEY, diff --git a/packages/ts-common/src/report/enum/report-status.enum.ts b/packages/ts-common/src/report/enum/report-status.enum.ts new file mode 100644 index 00000000..6c1cc170 --- /dev/null +++ b/packages/ts-common/src/report/enum/report-status.enum.ts @@ -0,0 +1,8 @@ +/** + * Enum for report status + */ +export enum ReportStatusEnum { + Processing = 'Processing', + Complete = 'Complete', + Error = 'Error', +} diff --git a/packages/ts-common/src/report/interfaces/report-creatable.interface.ts b/packages/ts-common/src/report/interfaces/report-creatable.interface.ts new file mode 100644 index 00000000..0bbb0710 --- /dev/null +++ b/packages/ts-common/src/report/interfaces/report-creatable.interface.ts @@ -0,0 +1,5 @@ +import { ReportInterface } from './report.interface'; + +export interface ReportCreatableInterface + extends Pick, + Partial> {} diff --git a/packages/ts-common/src/report/interfaces/report-updatable.interface.ts b/packages/ts-common/src/report/interfaces/report-updatable.interface.ts new file mode 100644 index 00000000..8f7d0c75 --- /dev/null +++ b/packages/ts-common/src/report/interfaces/report-updatable.interface.ts @@ -0,0 +1,5 @@ +import { ReportInterface } from './report.interface'; + +export interface ReportUpdatableInterface + extends Pick, + Partial> {} diff --git a/packages/ts-common/src/report/interfaces/report.interface.ts b/packages/ts-common/src/report/interfaces/report.interface.ts new file mode 100644 index 00000000..ba3af556 --- /dev/null +++ b/packages/ts-common/src/report/interfaces/report.interface.ts @@ -0,0 +1,34 @@ +import { AuditInterface, ReferenceIdInterface } from '@concepta/ts-core'; +import { ReportStatusEnum } from '../enum/report-status.enum'; + +/** + * Interface representing a report entity + */ +export interface ReportInterface extends ReferenceIdInterface, AuditInterface { + /** + * Service key associated with the report + */ + serviceKey: string; + + /** + * Report name of the report + */ + name: string; + + /** + * Status of the report + */ + status: ReportStatusEnum; + + /** + * Error message (null if no error) + */ + errorMessage: string | null; + + /** + * Dynamic download URI for the report + */ + downloadUrl?: string; + + file?: ReferenceIdInterface; +} diff --git a/tsconfig.json b/tsconfig.json index 8afa5f4e..eb6f9ab9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -115,6 +115,9 @@ }, { "path": "packages/nestjs-auth-apple" + }, + { + "path": "packages/nestjs-report" } ] } diff --git a/yarn.lock b/yarn.lock index 4a9b7deb..5a9254aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1068,7 +1068,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-file@workspace:packages/nestjs-file": +"@concepta/nestjs-file@npm:^5.0.0-alpha.3, @concepta/nestjs-file@workspace:packages/nestjs-file": version: 0.0.0-use.local resolution: "@concepta/nestjs-file@workspace:packages/nestjs-file" dependencies: @@ -1077,6 +1077,7 @@ __metadata: "@concepta/nestjs-typeorm-ext": "npm:^5.0.0-alpha.3" "@concepta/nestjs-user": "npm:^5.0.0-alpha.3" "@concepta/ts-common": "npm:^5.0.0-alpha.3" + "@concepta/ts-core": "npm:^5.0.0-alpha.3" "@concepta/typeorm-common": "npm:^5.0.0-alpha.3" "@nestjs/common": "npm:^10.4.1" "@nestjs/config": "npm:^3.2.3" @@ -1276,6 +1277,31 @@ __metadata: languageName: unknown linkType: soft +"@concepta/nestjs-report@workspace:packages/nestjs-report": + version: 0.0.0-use.local + resolution: "@concepta/nestjs-report@workspace:packages/nestjs-report" + dependencies: + "@concepta/nestjs-common": "npm:^5.0.0-alpha.3" + "@concepta/nestjs-exception": "npm:^5.0.0-alpha.3" + "@concepta/nestjs-file": "npm:^5.0.0-alpha.3" + "@concepta/nestjs-typeorm-ext": "npm:^5.0.0-alpha.3" + "@concepta/nestjs-user": "npm:^5.0.0-alpha.3" + "@concepta/ts-common": "npm:^5.0.0-alpha.3" + "@concepta/ts-core": "npm:^5.0.0-alpha.3" + "@concepta/typeorm-common": "npm:^5.0.0-alpha.3" + "@nestjs/common": "npm:^10.4.1" + "@nestjs/config": "npm:^3.2.3" + "@nestjs/swagger": "npm:^7.4.0" + "@nestjs/testing": "npm:^10.4.1" + jest-mock-extended: "npm:^2.0.9" + peerDependencies: + class-transformer: "*" + class-validator: "*" + rxjs: ^7.1.0 + typeorm: ^0.3.0 + languageName: unknown + linkType: soft + "@concepta/nestjs-role@workspace:packages/nestjs-role": version: 0.0.0-use.local resolution: "@concepta/nestjs-role@workspace:packages/nestjs-role"