diff --git a/.eslintrc.js b/.eslintrc.js index 1d8746721f..d7713cea3e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -234,7 +234,11 @@ module.exports = { excludedFiles: './**/*.cy.tsx', parser: '@typescript-eslint/parser', parserOptions: { project: './tsconfig.json' }, - rules: { ...rules }, + rules: { + ...rules, + 'no-empty-function': ['error', { allow: ['constructors'] }], + 'no-useless-constructor': 'off', + }, }, { files: ['./cypress/**/*.ts', './cypress/**/*.d.ts', './**/*.cy.tsx'], diff --git a/app/api/common.v2/AbstractController.ts b/app/api/common.v2/AbstractController.ts new file mode 100644 index 0000000000..e75318941c --- /dev/null +++ b/app/api/common.v2/AbstractController.ts @@ -0,0 +1,38 @@ +import { NextFunction, Request, Response } from 'express'; + +export type Dependencies = { + next: NextFunction; + res: Response; +}; + +export abstract class AbstractController { + protected next: NextFunction; + + protected res: Response; + + constructor({ next, res }: Dependencies) { + this.next = next; + this.res = res; + } + + abstract handle(request: Request): Promise; + + serverError(error: Error) { + // Logging ? + + return this.res.status(500).json({ + message: error.message, + }); + } + + clientError(message: string) { + // Should we log this ? + // What about negative impacts spam on Notifications channel ? + return this.res.status(400).json({ message }); + } + + jsonResponse(body: any) { + this.res.status(200).json(body); + this.next(); + } +} diff --git a/app/api/common.v2/contracts/UseCase.ts b/app/api/common.v2/contracts/UseCase.ts new file mode 100644 index 0000000000..584276aeb2 --- /dev/null +++ b/app/api/common.v2/contracts/UseCase.ts @@ -0,0 +1,3 @@ +export interface UseCase { + execute(input: Input): Promise; +} diff --git a/app/api/paragraphExtraction/application/PXCreateExtractor.ts b/app/api/paragraphExtraction/application/PXCreateExtractor.ts new file mode 100644 index 0000000000..c7b1a504e4 --- /dev/null +++ b/app/api/paragraphExtraction/application/PXCreateExtractor.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +import { UseCase } from 'api/common.v2/contracts/UseCase'; +import { TemplatesDataSource } from 'api/templates.v2/contracts/TemplatesDataSource'; + +import { IdGenerator } from 'api/common.v2/contracts/IdGenerator'; +import { PXExtractor } from '../domain/PXExtractor'; +import { PXExtractorsDataSource } from '../domain/PXExtractorDataSource'; +import { PXErrorCode, PXValidationError } from '../domain/PXValidationError'; + +type Input = z.infer; +type Output = PXExtractor; + +type Dependencies = { + templatesDS: TemplatesDataSource; + extractorDS: PXExtractorsDataSource; + idGenerator: IdGenerator; +}; + +const InputSchema = z.object({ + targetTemplateId: z.string({ message: 'You should provide a target template' }), + sourceTemplateId: z.string({ message: 'You should provide a source template' }), +}); + +export class PXCreateExtractor implements UseCase { + constructor(private dependencies: Dependencies) {} + + async execute(input: Input): Promise { + const [targetTemplate, sourceTemplate] = await Promise.all([ + this.dependencies.templatesDS.getById(input.targetTemplateId), + this.dependencies.templatesDS.getById(input.sourceTemplateId), + ]); + + if (!targetTemplate) { + throw new PXValidationError( + PXErrorCode.TARGET_TEMPLATE_NOT_FOUND, + `Target template with id ${input.targetTemplateId} was not found` + ); + } + + if (!sourceTemplate) { + throw new PXValidationError( + PXErrorCode.SOURCE_TEMPLATE_NOT_FOUND, + `Source template with id ${input.sourceTemplateId} was not found` + ); + } + + const extractor = new PXExtractor({ + id: this.dependencies.idGenerator.generate(), + targetTemplate, + sourceTemplate, + }); + + await this.dependencies.extractorDS.create(extractor); + + return extractor; + } +} diff --git a/app/api/paragraphExtraction/application/specs/PXCreateExtractor.spec.ts b/app/api/paragraphExtraction/application/specs/PXCreateExtractor.spec.ts new file mode 100644 index 0000000000..d6a175d953 --- /dev/null +++ b/app/api/paragraphExtraction/application/specs/PXCreateExtractor.spec.ts @@ -0,0 +1,129 @@ +import { ObjectId } from 'mongodb'; + +import { DefaultTransactionManager } from 'api/common.v2/database/data_source_defaults'; +import { getConnection } from 'api/common.v2/database/getConnectionForCurrentTenant'; +import { MongoIdHandler } from 'api/common.v2/database/MongoIdGenerator'; +import { DefaultTemplatesDataSource } from 'api/templates.v2/database/data_source_defaults'; +import { getFixturesFactory } from 'api/utils/fixturesFactory'; +import { testingEnvironment } from 'api/utils/testingEnvironment'; + +import { PXErrorCode } from 'api/paragraphExtraction/domain/PXValidationError'; +import { + mongoPXExtractorsCollection, + MongoPXExtractorsDataSource, +} from '../../infrastructure/MongoPXExtractorsDataSource'; +import { PXCreateExtractor } from '../PXCreateExtractor'; + +const factory = getFixturesFactory(); + +const setUpUseCase = () => { + const transaction = DefaultTransactionManager(); + const templatesDS = DefaultTemplatesDataSource(transaction); + + const extractorDS = new MongoPXExtractorsDataSource(getConnection(), transaction); + + return { + createExtractor: new PXCreateExtractor({ + extractorDS, + templatesDS, + idGenerator: MongoIdHandler, + }), + }; +}; + +const sourceTemplate = factory.template('Source Template', [factory.property('text', 'text')]); +const targetTemplate = factory.template('Target Template', [ + factory.property('rich_text', 'markdown'), +]); +const invalidTargetTemplate = factory.template('Invalid Target'); + +describe('PXCreateExtractor', () => { + beforeEach(async () => { + await testingEnvironment.setUp({ + templates: [sourceTemplate, targetTemplate, invalidTargetTemplate], + }); + }); + + afterAll(async () => { + await testingEnvironment.tearDown(); + }); + + it('should create an Extractor correctly', async () => { + const { createExtractor } = setUpUseCase(); + + await createExtractor.execute({ + sourceTemplateId: sourceTemplate._id.toString(), + targetTemplateId: targetTemplate._id.toString(), + }); + + const dbPXExtractors = await testingEnvironment.db.getAllFrom(mongoPXExtractorsCollection); + + expect(dbPXExtractors).toEqual([ + { + _id: expect.any(ObjectId), + sourceTemplateId: expect.any(ObjectId), + targetTemplateId: expect.any(ObjectId), + }, + ]); + }); + + it('should throw if target Template does not exist', async () => { + const { createExtractor } = setUpUseCase(); + const targetTemplateId = new ObjectId().toString(); + + const promise = createExtractor.execute({ + sourceTemplateId: sourceTemplate._id.toString(), + targetTemplateId, + }); + + await expect(promise).rejects.toMatchObject({ + code: PXErrorCode.TARGET_TEMPLATE_NOT_FOUND, + }); + }); + + it('should throw if source Template does not exist', async () => { + const { createExtractor } = setUpUseCase(); + const sourceTemplateId = new ObjectId().toString(); + + const promise = createExtractor.execute({ + targetTemplateId: sourceTemplate._id.toString(), + sourceTemplateId, + }); + + await expect(promise).rejects.toMatchObject({ + code: PXErrorCode.SOURCE_TEMPLATE_NOT_FOUND, + }); + }); + + it('should throw if target template is not valid for create an Extractor', async () => { + const { createExtractor } = setUpUseCase(); + + const promise = createExtractor.execute({ + sourceTemplateId: sourceTemplate._id.toString(), + targetTemplateId: invalidTargetTemplate._id.toString(), + }); + + await expect(promise).rejects.toMatchObject({ + code: PXErrorCode.TARGET_TEMPLATE_INVALID, + }); + + const dbPXExtractors = await testingEnvironment.db.getAllFrom(mongoPXExtractorsCollection); + expect(dbPXExtractors).toEqual([]); + }); + + it('should throw if target and source template are the same', async () => { + const { createExtractor } = setUpUseCase(); + + const promise = createExtractor.execute({ + sourceTemplateId: targetTemplate._id.toString(), + targetTemplateId: targetTemplate._id.toString(), + }); + + await expect(promise).rejects.toMatchObject({ + code: PXErrorCode.TARGET_SOURCE_TEMPLATE_EQUAL, + }); + + const dbPXExtractors = await testingEnvironment.db.getAllFrom(mongoPXExtractorsCollection); + expect(dbPXExtractors).toEqual([]); + }); +}); diff --git a/app/api/paragraphExtraction/domain/PXExtractor.ts b/app/api/paragraphExtraction/domain/PXExtractor.ts new file mode 100644 index 0000000000..3a4b3901a3 --- /dev/null +++ b/app/api/paragraphExtraction/domain/PXExtractor.ts @@ -0,0 +1,40 @@ +import { Template } from 'api/templates.v2/model/Template'; +import { PXValidationError, PXErrorCode } from './PXValidationError'; + +export type PXExtractorProps = { + id: string; + sourceTemplate: Template; + targetTemplate: Template; +}; + +export class PXExtractor { + id: string; + + targetTemplate: Template; + + sourceTemplate: Template; + + constructor(props: PXExtractorProps) { + this.id = props.id; + this.targetTemplate = props.targetTemplate; + this.sourceTemplate = props.sourceTemplate; + + this.validate(); + } + + private validate() { + if (!this.targetTemplate.getPropertiesByType('markdown').length) { + throw new PXValidationError( + PXErrorCode.TARGET_TEMPLATE_INVALID, + `Target template with id ${this.targetTemplate.id} should have at least one rich text property` + ); + } + + if (this.targetTemplate.id === this.sourceTemplate.id) { + throw new PXValidationError( + PXErrorCode.TARGET_SOURCE_TEMPLATE_EQUAL, + 'Target and Source template cannot be the same' + ); + } + } +} diff --git a/app/api/paragraphExtraction/domain/PXExtractorDataSource.ts b/app/api/paragraphExtraction/domain/PXExtractorDataSource.ts new file mode 100644 index 0000000000..3644608639 --- /dev/null +++ b/app/api/paragraphExtraction/domain/PXExtractorDataSource.ts @@ -0,0 +1,5 @@ +import { PXExtractor } from './PXExtractor'; + +export interface PXExtractorsDataSource { + create(extractor: PXExtractor): Promise; +} diff --git a/app/api/paragraphExtraction/domain/PXValidationError.ts b/app/api/paragraphExtraction/domain/PXValidationError.ts new file mode 100644 index 0000000000..46d42ef715 --- /dev/null +++ b/app/api/paragraphExtraction/domain/PXValidationError.ts @@ -0,0 +1,17 @@ +export enum PXErrorCode { + TARGET_TEMPLATE_NOT_FOUND = 'TARGET_TEMPLATE_NOT_FOUND', + SOURCE_TEMPLATE_NOT_FOUND = 'SOURCE_TEMPLATE_NOT_FOUND', + TARGET_TEMPLATE_INVALID = 'TARGET_TEMPLATE_INVALID', + TARGET_SOURCE_TEMPLATE_EQUAL = 'TARGET_SOURCE_TEMPLATE_EQUAL', +} + +export class PXValidationError extends Error { + constructor( + public code: PXErrorCode, + message: string, + options?: ErrorOptions + ) { + super(message, options); + this.name = 'PXValidationError'; + } +} diff --git a/app/api/paragraphExtraction/infrastructure/MongoPXExtractorDBO.ts b/app/api/paragraphExtraction/infrastructure/MongoPXExtractorDBO.ts new file mode 100644 index 0000000000..0aadec5b97 --- /dev/null +++ b/app/api/paragraphExtraction/infrastructure/MongoPXExtractorDBO.ts @@ -0,0 +1,7 @@ +import { ObjectId } from 'mongodb'; + +export type MongoPXExtractorDBO = { + _id: ObjectId; + targetTemplateId: ObjectId; + sourceTemplateId: ObjectId; +}; diff --git a/app/api/paragraphExtraction/infrastructure/MongoPXExtractorsDataSource.ts b/app/api/paragraphExtraction/infrastructure/MongoPXExtractorsDataSource.ts new file mode 100644 index 0000000000..6717ccedfa --- /dev/null +++ b/app/api/paragraphExtraction/infrastructure/MongoPXExtractorsDataSource.ts @@ -0,0 +1,24 @@ +import { MongoDataSource } from 'api/common.v2/database/MongoDataSource'; +import { ObjectId } from 'mongodb'; +import { PXExtractor } from '../domain/PXExtractor'; +import { PXExtractorsDataSource } from '../domain/PXExtractorDataSource'; +import { MongoPXExtractorDBO } from './MongoPXExtractorDBO'; + +export const mongoPXExtractorsCollection = 'px_extractors'; + +export class MongoPXExtractorsDataSource + extends MongoDataSource + implements PXExtractorsDataSource +{ + protected collectionName = mongoPXExtractorsCollection; + + async create(extractor: PXExtractor): Promise { + const mongoExtractor: MongoPXExtractorDBO = { + _id: new ObjectId(extractor.id), + sourceTemplateId: new ObjectId(extractor.sourceTemplate.id), + targetTemplateId: new ObjectId(extractor.targetTemplate.id), + }; + + await this.getCollection().insertOne(mongoExtractor); + } +} diff --git a/app/api/templates.v2/contracts/TemplatesDataSource.ts b/app/api/templates.v2/contracts/TemplatesDataSource.ts index f27359adad..520918cd1f 100644 --- a/app/api/templates.v2/contracts/TemplatesDataSource.ts +++ b/app/api/templates.v2/contracts/TemplatesDataSource.ts @@ -4,6 +4,7 @@ import { RelationshipProperty } from '../model/RelationshipProperty'; import { Template } from '../model/Template'; export interface TemplatesDataSource { + getAll(): ResultSet