From 650f8f596327d1a5c1abfa162557563ab8229461 Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 12 May 2025 10:32:04 +0200 Subject: [PATCH] =?UTF-8?q?Revert=20"Revert=20"[4/n]:=20migrate=20the=20RE?= =?UTF-8?q?STAPI=20GET=20/rest/*=20to=20use=20TwentyORM=20direc=E2=80=A6"?= =?UTF-8?q?=20(#11349)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- packages/twenty-server/src/app.module.ts | 1 + .../__tests__/workspace.factory.spec.ts | 5 + .../graphql-query-filter-field.parser.ts | 2 +- .../graphql-query-order.parser.ts | 8 +- .../graphql-query.parser.ts | 8 +- .../api/graphql/workspace-schema.factory.ts | 9 +- .../controllers/rest-api-core.controller.ts | 10 +- .../handlers/rest-api-create-one.handler.ts | 58 +++ .../handlers/rest-api-delete-one.handler.ts | 40 ++ .../handlers/rest-api-get-many.handler.ts | 37 ++ .../core/handlers/rest-api-get-one.handler.ts | 51 +++ .../handlers/rest-api-update-one.handler.ts | 63 +++ .../core/interfaces/rest-api-base.handler.ts | 416 ++++++++++++++++++ .../core-query-builder.factory.ts | 86 +--- .../factories/create-one-query.factory.ts | 39 -- .../factories/delete-query.factory.ts | 20 - .../factories/delete-variables.factory.ts | 12 - .../core/query-builder/factories/factories.ts | 16 +- .../factories/find-many-query.factory.ts | 65 --- .../factories/find-one-query.factory.ts | 40 -- .../factories/get-variables.factory.ts | 6 +- .../factories/update-query.factory.ts | 40 -- .../factories/update-variables.factory.ts | 15 - .../api/rest/core/rest-api-core-v2.service.ts | 197 +-------- .../api/rest/core/rest-api-core.module.ts | 51 +++ .../api/rest/core/rest-api-core.service.ts | 24 - .../rest/core/types/query-variables.type.ts | 4 +- .../input-factories/depth-input.factory.ts | 32 ++ .../api/rest/input-factories/factories.ts | 6 +- .../src/engine/api/rest/rest-api.module.ts | 40 +- .../services/cache-storage.service.ts | 24 +- .../workspace-permissions-cache.service.ts | 4 +- .../factories/workspace-datasource.factory.ts | 7 +- .../initial-person-data.constants.ts | 16 - .../constants/mock-person-ids.constants.ts | 4 - .../constants/test-company-ids.constants.ts | 1 + .../constants/test-person-ids.constants.ts | 6 + .../test-primary-link-url.constant.ts | 1 + .../all-people-resolvers.integration-spec.ts | 53 ++- .../graphql/suites/auth.integration-spec.ts | 2 +- .../data-model.integration-spec.ts | 2 - ...st-api-core-create-one.integration-spec.ts | 117 ++++- .../rest-api-core-delete.integration-spec.ts | 57 ++- ...est-api-core-find-many.integration-spec.ts | 289 ++++++++++++ ...rest-api-core-find-one.integration-spec.ts | 119 +++++ .../rest-api-core-update.integration-spec.ts | 108 ++++- .../integration/utils/delete-all-records.ts | 11 + .../test/integration/utils/setup-test.ts | 5 + .../test/integration/utils/teardown-test.ts | 1 + 50 files changed, 1532 insertions(+), 698 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts create mode 100644 packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-delete-one.handler.ts create mode 100644 packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-get-many.handler.ts create mode 100644 packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-get-one.handler.ts create mode 100644 packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-update-one.handler.ts create mode 100644 packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/core/query-builder/factories/create-one-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/core/query-builder/factories/delete-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/core/query-builder/factories/delete-variables.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/core/query-builder/factories/find-many-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/core/query-builder/factories/find-one-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/core/query-builder/factories/update-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/core/query-builder/factories/update-variables.factory.ts create mode 100644 packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-factories/depth-input.factory.ts delete mode 100644 packages/twenty-server/test/integration/constants/initial-person-data.constants.ts delete mode 100644 packages/twenty-server/test/integration/constants/mock-person-ids.constants.ts create mode 100644 packages/twenty-server/test/integration/constants/test-company-ids.constants.ts create mode 100644 packages/twenty-server/test/integration/constants/test-person-ids.constants.ts create mode 100644 packages/twenty-server/test/integration/constants/test-primary-link-url.constant.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/rest-api-core-find-one.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/utils/delete-all-records.ts diff --git a/Makefile b/Makefile index f74579943..435473946 100644 --- a/Makefile +++ b/Makefile @@ -20,4 +20,4 @@ redis-on-docker: docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest clickhouse-on-docker: - docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \ No newline at end of file + docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=clickhousePassword clickhouse/clickhouse-server:latest \ diff --git a/packages/twenty-server/src/app.module.ts b/packages/twenty-server/src/app.module.ts index 4ce85442d..f422d2026 100644 --- a/packages/twenty-server/src/app.module.ts +++ b/packages/twenty-server/src/app.module.ts @@ -38,6 +38,7 @@ const MIGRATED_REST_METHODS = [ RequestMethod.POST, RequestMethod.PATCH, RequestMethod.PUT, + RequestMethod.GET, ]; @Module({ diff --git a/packages/twenty-server/src/engine/api/graphql/__tests__/workspace.factory.spec.ts b/packages/twenty-server/src/engine/api/graphql/__tests__/workspace.factory.spec.ts index 8787bc320..2d30212fd 100644 --- a/packages/twenty-server/src/engine/api/graphql/__tests__/workspace.factory.spec.ts +++ b/packages/twenty-server/src/engine/api/graphql/__tests__/workspace.factory.spec.ts @@ -9,6 +9,7 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data- import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; describe('WorkspaceSchemaFactory', () => { let service: WorkspaceSchemaFactory; @@ -49,6 +50,10 @@ describe('WorkspaceSchemaFactory', () => { provide: FeatureFlagService, useValue: {}, }, + { + provide: TwentyConfigService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts index b514febfb..7a9f4d9dc 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts @@ -1,8 +1,8 @@ import { capitalize } from 'twenty-shared/utils'; import { WhereExpressionBuilder } from 'typeorm'; -import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { GraphqlQueryRunnerException, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts index 40eda0e71..a19fa7fab 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts @@ -4,7 +4,6 @@ import { ObjectRecordOrderBy, OrderByDirection, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; -import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { @@ -18,14 +17,9 @@ import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspac export class GraphqlQueryOrderFieldParser { private fieldMetadataMapByName: FieldMetadataMap; - private featureFlagsMap: FeatureFlagMap; - constructor( - fieldMetadataMapByName: FieldMetadataMap, - featureFlagsMap: FeatureFlagMap, - ) { + constructor(fieldMetadataMapByName: FieldMetadataMap) { this.fieldMetadataMapByName = fieldMetadataMapByName; - this.featureFlagsMap = featureFlagsMap; } parse( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index b3769252e..5459e094b 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -21,6 +21,10 @@ import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metada import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; export class GraphqlQueryParser { private fieldMetadataMapByName: FieldMetadataMap; @@ -47,7 +51,6 @@ export class GraphqlQueryParser { ); this.orderFieldParser = new GraphqlQueryOrderFieldParser( this.fieldMetadataMapByName, - featureFlagsMap, ); } @@ -125,8 +128,9 @@ export class GraphqlQueryParser { )?.fieldsByName; if (!parentFields) { - throw new Error( + throw new GraphqlQueryRunnerException( `Could not find object metadata for ${parentObjectMetadata.nameSingular}`, + GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND, ); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index 526679fc9..d1f4f0167 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -6,6 +6,8 @@ import { GraphQLSchema, printSchema } from 'graphql'; import { gql } from 'graphql-tag'; import { isDefined } from 'twenty-shared/utils'; +import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; + import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, @@ -21,6 +23,7 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data- import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class WorkspaceSchemaFactory { @@ -32,6 +35,7 @@ export class WorkspaceSchemaFactory { private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, private readonly featureFlagService: FeatureFlagService, + private readonly twentyConfigService: TwentyConfigService, ) {} async createGraphQLSchema(authContext: AuthContext): Promise { @@ -44,7 +48,10 @@ export class WorkspaceSchemaFactory { authContext.workspace.id, ); - if (isNewRelationEnabled) { + if ( + isNewRelationEnabled && + this.twentyConfigService.get('NODE_ENV') !== NodeEnvironment.test + ) { // eslint-disable-next-line no-console console.log( chalk.yellow('🚧 New relation schema generation is enabled 🚧'), diff --git a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts index f904d2ccd..85215e2b1 100644 --- a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts +++ b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts @@ -22,6 +22,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Controller('rest/*') @UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseFilters(RestApiExceptionFilter) export class RestApiCoreController { constructor( private readonly restApiCoreService: RestApiCoreService, @@ -38,15 +39,12 @@ export class RestApiCoreController { @Get() async handleApiGet(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiCoreService.get(request); + const result = await this.restApiCoreServiceV2.get(request); - res.status(200).send(cleanGraphQLResponse(result.data.data)); + res.status(200).send(result); } @Delete() - // We should move this exception filter to RestApiCoreController class level - // when all endpoints are migrated to v2 - @UseFilters(RestApiExceptionFilter) async handleApiDelete(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.delete(request); @@ -54,7 +52,6 @@ export class RestApiCoreController { } @Post() - @UseFilters(RestApiExceptionFilter) async handleApiPost(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.createOne(request); @@ -73,7 +70,6 @@ export class RestApiCoreController { // We keep it to avoid a breaking change since it initially used PUT instead of PATCH, // and because the PUT verb is often used as a PATCH. @Put() - @UseFilters(RestApiExceptionFilter) async handleApiPut(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.update(request); diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts new file mode 100644 index 000000000..0ad672d07 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts @@ -0,0 +1,58 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; + +import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; + +@Injectable() +export class RestApiCreateOneHandler extends RestApiBaseHandler { + async handle(request: Request) { + const { objectMetadataNameSingular, objectMetadata, repository } = + await this.getRepositoryAndMetadataOrFail(request); + + const overriddenBody = await this.recordInputTransformerService.process({ + recordInput: request.body, + objectMetadataMapItem: objectMetadata.objectMetadataMapItem, + }); + + const recordExists = + isDefined(overriddenBody.id) && + (await repository.exists({ + where: { + id: overriddenBody.id, + }, + })); + + if (recordExists) { + throw new BadRequestException('Record already exists'); + } + + const createdRecord = await repository.save(overriddenBody); + + this.apiEventEmitterService.emitCreateEvents( + [createdRecord], + this.getAuthContextFromRequest(request), + objectMetadata.objectMetadataMapItem, + ); + + const records = await this.getRecord({ + recordIds: [createdRecord.id], + repository, + objectMetadata, + depth: this.depthInputFactory.create(request), + }); + + const record = records[0]; + + if (!isDefined(record)) { + throw new Error('Created record not found'); + } + + return this.formatResult({ + operation: 'create', + objectNameSingular: objectMetadataNameSingular, + data: record, + }); + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-delete-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-delete-one.handler.ts new file mode 100644 index 000000000..f5ae29e8c --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-delete-one.handler.ts @@ -0,0 +1,40 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; + +import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; + +@Injectable() +export class RestApiDeleteOneHandler extends RestApiBaseHandler { + async handle(request: Request) { + const { id: recordId } = parseCorePath(request); + + if (!recordId) { + throw new BadRequestException('Record ID not found'); + } + + const { objectMetadataNameSingular, objectMetadata, repository } = + await this.getRepositoryAndMetadataOrFail(request); + const recordToDelete = await repository.findOneOrFail({ + where: { id: recordId }, + }); + + await repository.delete(recordId); + + this.apiEventEmitterService.emitDestroyEvents( + [recordToDelete], + this.getAuthContextFromRequest(request), + objectMetadata.objectMetadataMapItem, + ); + + return this.formatResult({ + operation: 'delete', + objectNameSingular: objectMetadataNameSingular, + data: { + id: recordToDelete.id, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-get-many.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-get-many.handler.ts new file mode 100644 index 000000000..a81171852 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-get-many.handler.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; + +@Injectable() +export class RestApiGetManyHandler extends RestApiBaseHandler { + async handle(request: Request) { + const { + objectMetadataNameSingular, + objectMetadataNamePlural, + repository, + dataSource, + objectMetadata, + objectMetadataItemWithFieldsMaps, + } = await this.getRepositoryAndMetadataOrFail(request); + + const { records, isForwardPagination, hasMoreRecords, totalCount } = + await this.findRecords({ + request, + repository, + dataSource, + objectMetadata, + objectMetadataNameSingular, + objectMetadataItemWithFieldsMaps, + }); + + return this.formatPaginatedResult( + records, + objectMetadataNamePlural, + isForwardPagination, + hasMoreRecords, + totalCount, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-get-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-get-one.handler.ts new file mode 100644 index 000000000..e7b64400d --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-get-one.handler.ts @@ -0,0 +1,51 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; + +import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; + +import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; + +@Injectable() +export class RestApiGetOneHandler extends RestApiBaseHandler { + async handle(request: Request) { + const { id: recordId } = parseCorePath(request); + + if (!isDefined(recordId)) { + throw new BadRequestException( + 'No recordId provided in rest api get one query', + ); + } + + const { + objectMetadataNameSingular, + repository, + dataSource, + objectMetadata, + objectMetadataItemWithFieldsMaps, + } = await this.getRepositoryAndMetadataOrFail(request); + + const { records } = await this.findRecords({ + request, + recordId, + repository, + dataSource, + objectMetadata, + objectMetadataNameSingular, + objectMetadataItemWithFieldsMaps, + }); + + const record = records?.[0]; + + if (!isDefined(record)) { + throw new BadRequestException('Record not found'); + } + + return this.formatResult({ + operation: 'findOne', + objectNameSingular: objectMetadataNameSingular, + data: record, + }); + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-update-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-update-one.handler.ts new file mode 100644 index 000000000..5bc0d2a6d --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-update-one.handler.ts @@ -0,0 +1,63 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; + +import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; + +import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; + +@Injectable() +export class RestApiUpdateOneHandler extends RestApiBaseHandler { + async handle(request: Request) { + const { id: recordId } = parseCorePath(request); + + if (!recordId) { + throw new BadRequestException('Record ID not found'); + } + + const { objectMetadataNameSingular, objectMetadata, repository } = + await this.getRepositoryAndMetadataOrFail(request); + + const recordToUpdate = await repository.findOneOrFail({ + where: { id: recordId }, + }); + + const overriddenBody = await this.recordInputTransformerService.process({ + recordInput: request.body, + objectMetadataMapItem: objectMetadata.objectMetadataMapItem, + }); + + const updatedRecord = await repository.save({ + ...recordToUpdate, + ...overriddenBody, + }); + + this.apiEventEmitterService.emitUpdateEvents( + [recordToUpdate], + [updatedRecord], + Object.keys(request.body), + this.getAuthContextFromRequest(request), + objectMetadata.objectMetadataMapItem, + ); + + const records = await this.getRecord({ + recordIds: [updatedRecord.id], + repository, + objectMetadata, + depth: this.depthInputFactory.create(request), + }); + + const record = records[0]; + + if (!isDefined(record)) { + throw new Error('Updated record not found'); + } + + return this.formatResult({ + operation: 'update', + objectNameSingular: objectMetadataNameSingular, + data: record, + }); + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts b/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts new file mode 100644 index 000000000..81e880e27 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts @@ -0,0 +1,416 @@ +import { BadRequestException, Inject } from '@nestjs/common'; + +import { Request } from 'express'; +import { capitalize, isDefined } from 'twenty-shared/utils'; +import { In, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { + ObjectRecord, + OrderByDirection, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory'; +import { + Depth, + DepthInputFactory, + MAX_DEPTH, +} from 'src/engine/api/rest/input-factories/depth-input.factory'; +import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; +import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; +import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; +import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type'; + +export interface PageInfo { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + startCursor: string | null; + endCursor: string | null; +} + +interface FormatResultParams { + operation: 'delete' | 'create' | 'update' | 'findOne' | 'findMany'; + objectNameSingular?: string; + objectNamePlural?: string; + data: T; + pageInfo?: PageInfo; + totalCount?: number; +} + +export interface FormatResult { + data: { + [operation: string]: object; + }; + pageInfo?: PageInfo; + totalCount?: number; +} + +export abstract class RestApiBaseHandler { + @Inject() + protected readonly recordInputTransformerService: RecordInputTransformerService; + @Inject() + protected readonly coreQueryBuilderFactory: CoreQueryBuilderFactory; + @Inject() + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager; + @Inject() + protected readonly getVariablesFactory: GetVariablesFactory; + @Inject() + protected readonly depthInputFactory: DepthInputFactory; + @Inject() + protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService; + @Inject() + protected readonly apiEventEmitterService: ApiEventEmitterService; + + protected abstract handle(request: Request): Promise; + + public async getRepositoryAndMetadataOrFail(request: Request) { + const { workspace, apiKey, userWorkspaceId } = request; + const { object: parsedObject } = parseCorePath(request); + + const objectMetadata = await this.coreQueryBuilderFactory.getObjectMetadata( + request, + parsedObject, + ); + + if (!objectMetadata) { + throw new BadRequestException('Object metadata not found'); + } + + if (!workspace?.id) { + throw new BadRequestException('Workspace not found'); + } + + const dataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace({ + workspaceId: workspace.id, + shouldFailIfMetadataNotFound: false, + }); + + const objectMetadataNameSingular = + objectMetadata.objectMetadataMapItem.nameSingular; + + const objectMetadataItemWithFieldsMaps = + getObjectMetadataMapItemByNameSingular( + objectMetadata.objectMetadataMaps, + objectMetadataNameSingular, + ); + + const shouldBypassPermissionChecks = !!apiKey; + + const roleId = + await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId({ + workspaceId: workspace.id, + userWorkspaceId, + }); + + const repository = dataSource.getRepository( + objectMetadataNameSingular, + shouldBypassPermissionChecks, + roleId, + ); + + return { + objectMetadataNameSingular, + objectMetadataNamePlural: objectMetadata.objectMetadataMapItem.namePlural, + objectMetadata, + repository, + dataSource, + objectMetadataItemWithFieldsMaps, + }; + } + + getRelations({ + objectMetadata, + depth, + }: { + objectMetadata: { + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; + }; + depth: Depth | undefined; + }) { + if (!isDefined(depth) || depth === 0) { + return []; + } + + const relations: string[] = []; + + objectMetadata.objectMetadataMapItem.fields.forEach((field) => { + if (field.type === FieldMetadataType.RELATION) { + if ( + depth === MAX_DEPTH && + isDefined(field.relationTargetObjectMetadataId) + ) { + const relationTargetObjectMetadata = + objectMetadata.objectMetadataMaps.byId[ + field.relationTargetObjectMetadataId + ]; + const depth2Relations = this.getRelations({ + objectMetadata: { + objectMetadataMaps: objectMetadata.objectMetadataMaps, + objectMetadataMapItem: relationTargetObjectMetadata, + }, + depth: 1, + }); + + depth2Relations.forEach((depth2Relation) => { + relations.push(`${field.name}.${depth2Relation}`); + }); + } else { + relations.push(`${field.name}`); + } + } + }); + + return relations; + } + + async getRecord({ + recordIds, + repository, + objectMetadata, + depth, + }: { + recordIds: string[]; + repository: WorkspaceRepository; + objectMetadata: { + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; + }; + depth: Depth | undefined; + }) { + const relations = this.getRelations({ + objectMetadata, + depth: depth, + }); + + const unorderedRecords = await repository.find({ + where: { id: In(recordIds) }, + relations, + }); + + const recordMap = new Map(unorderedRecords.map((r) => [r.id, r])); + + const orderedRecords = recordIds.map((id) => recordMap.get(id)); + + return orderedRecords; + } + + public getAuthContextFromRequest(request: Request): AuthContext { + return { + user: request.user, + workspace: request.workspace, + apiKey: request.apiKey, + workspaceMemberId: request.workspaceMemberId, + userWorkspaceId: request.userWorkspaceId, + }; + } + + public formatResult({ + operation, + objectNameSingular, + objectNamePlural, + data, + pageInfo, + totalCount, + }: FormatResultParams) { + let prefix: string; + + if (operation === 'findOne') { + prefix = objectNameSingular || ''; + } else if (operation === 'findMany') { + prefix = objectNamePlural || ''; + } else { + prefix = operation + capitalize(objectNameSingular || ''); + } + + return { + data: { + [prefix]: data, + }, + ...(isDefined(pageInfo) ? { pageInfo } : {}), + ...(isDefined(totalCount) ? { totalCount } : {}), + }; + } + + formatPaginatedResult( + finalRecords: any[], + objectMetadataNamePlural: string, + isForwardPagination: boolean, + hasMoreRecords: boolean, + totalCount: number, + ) { + const hasPreviousPage = !isForwardPagination && hasMoreRecords; + + return this.formatResult({ + operation: 'findMany', + objectNamePlural: objectMetadataNamePlural, + data: isForwardPagination ? finalRecords : finalRecords.reverse(), + pageInfo: { + hasNextPage: isForwardPagination && hasMoreRecords, + ...(hasPreviousPage ? { hasPreviousPage } : {}), + startCursor: + finalRecords.length > 0 + ? Buffer.from(JSON.stringify({ id: finalRecords[0].id })).toString( + 'base64', + ) + : null, + endCursor: + finalRecords.length > 0 + ? Buffer.from( + JSON.stringify({ + id: finalRecords[finalRecords.length - 1].id, + }), + ).toString('base64') + : null, + }, + totalCount, + }); + } + + async findRecords({ + request, + recordId, + repository, + dataSource, + objectMetadata, + objectMetadataNameSingular, + objectMetadataItemWithFieldsMaps, + }: { + request: Request; + recordId?: string; + repository: WorkspaceRepository; + dataSource: WorkspaceDataSource; + objectMetadata: any; + objectMetadataNameSingular: string; + objectMetadataItemWithFieldsMaps: + | ObjectMetadataItemWithFieldMaps + | undefined; + }) { + const qb = repository.createQueryBuilder(objectMetadataNameSingular); + + const inputs = this.getVariablesFactory.create( + recordId, + request, + objectMetadata, + ); + + const fieldMetadataMapByName = + objectMetadataItemWithFieldsMaps?.fieldsByName || {}; + const fieldMetadataMapByJoinColumnName = + objectMetadataItemWithFieldsMaps?.fieldsByJoinColumnName || {}; + + const graphqlQueryParser = new GraphqlQueryParser( + fieldMetadataMapByName, + fieldMetadataMapByJoinColumnName, + objectMetadata.objectMetadataMaps, + dataSource.featureFlagMap, + ); + + const filters = this.computeFilters(inputs); + + let selectQueryBuilder = isDefined(filters) + ? graphqlQueryParser.applyFilterToBuilder( + qb, + objectMetadataNameSingular, + filters, + ) + : qb; + + const totalCount = await this.getTotalCount(selectQueryBuilder); + + const isForwardPagination = !inputs.endingBefore; + + selectQueryBuilder = graphqlQueryParser.applyOrderToBuilder( + selectQueryBuilder, + [...(inputs.orderBy || []), { id: OrderByDirection.AscNullsFirst }], + objectMetadataNameSingular, + isForwardPagination, + ); + + if (inputs.first) { + selectQueryBuilder = selectQueryBuilder.limit(inputs.first); + } + + if (inputs.last) { + selectQueryBuilder = selectQueryBuilder.limit(inputs.last); + } + + const recordIds = await selectQueryBuilder + .select(`${objectMetadataNameSingular}.id`) + .getMany(); + + const records = await this.getRecord({ + recordIds: recordIds.map((record) => record.id), + repository, + objectMetadata, + depth: this.depthInputFactory.create(request), + }); + + const hasMoreRecords = records.length < totalCount; + + return { + records: formatGetManyData( + records, + objectMetadataItemWithFieldsMaps as any, + objectMetadata.objectMetadataMaps, + dataSource.featureFlagMap[FeatureFlagKey.IsNewRelationEnabled], + ), + totalCount, + hasMoreRecords, + isForwardPagination, + }; + } + + async getTotalCount( + query: SelectQueryBuilder, + ): Promise { + const countQuery = query.clone(); + + return await countQuery.getCount(); + } + + computeFilters(inputs: QueryVariables) { + let appliedFilters = inputs.filter; + + if (inputs.startingAfter) { + appliedFilters = { + and: [ + appliedFilters || {}, + { id: { gt: this.parseCursor(inputs.startingAfter).id } }, + ], + }; + } + + if (inputs.endingBefore) { + appliedFilters = { + and: [ + appliedFilters || {}, + { id: { lt: this.parseCursor(inputs.endingBefore).id } }, + ], + }; + } + + return appliedFilters; + } + + private parseCursor = (cursor: string) => { + try { + return JSON.parse(Buffer.from(cursor ?? '', 'base64').toString()); + } catch (error) { + throw new BadRequestException(`Invalid cursor: ${cursor}`); + } + }; +} diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts index f79e41b63..882b2518c 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts @@ -3,17 +3,8 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { Request } from 'express'; import { CreateManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-many-query.factory'; -import { CreateOneQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-one-query.factory'; -import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory'; -import { DeleteQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/delete-query.factory'; -import { DeleteVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/delete-variables.factory'; import { FindDuplicatesQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-query.factory'; import { FindDuplicatesVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-variables.factory'; -import { FindManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-many-query.factory'; -import { FindOneQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-one-query.factory'; -import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory'; -import { UpdateQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/update-query.factory'; -import { UpdateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/update-variables.factory'; import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compute-depth.utils'; import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; @@ -26,21 +17,14 @@ import { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-module import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory'; @Injectable() export class CoreQueryBuilderFactory { constructor( - private readonly deleteQueryFactory: DeleteQueryFactory, - private readonly createOneQueryFactory: CreateOneQueryFactory, private readonly createManyQueryFactory: CreateManyQueryFactory, - private readonly updateQueryFactory: UpdateQueryFactory, - private readonly findOneQueryFactory: FindOneQueryFactory, - private readonly findManyQueryFactory: FindManyQueryFactory, private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory, - private readonly deleteVariablesFactory: DeleteVariablesFactory, private readonly createVariablesFactory: CreateVariablesFactory, - private readonly updateVariablesFactory: UpdateVariablesFactory, - private readonly getVariablesFactory: GetVariablesFactory, private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory, private readonly accessTokenService: AccessTokenService, private readonly domainManagerService: DomainManagerService, @@ -113,38 +97,6 @@ export class CoreQueryBuilderFactory { }; } - async delete(request: Request): Promise { - const { object: parsedObject } = parseCorePath(request); - const objectMetadata = await this.getObjectMetadata(request, parsedObject); - - const { id } = parseCorePath(request); - - if (!id) { - throw new BadRequestException( - `delete ${objectMetadata.objectMetadataMapItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataMapItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, - ); - } - - return { - query: this.deleteQueryFactory.create( - objectMetadata.objectMetadataMapItem, - ), - variables: this.deleteVariablesFactory.create(id), - }; - } - - async createOne(request: Request): Promise { - const { object: parsedObject } = parseCorePath(request); - const objectMetadata = await this.getObjectMetadata(request, parsedObject); - - const depth = computeDepth(request); - - return { - query: this.createOneQueryFactory.create(objectMetadata, depth), - variables: this.createVariablesFactory.create(request), - }; - } - async createMany(request: Request): Promise { const { object: parsedObject } = parseCoreBatchPath(request); const objectMetadata = await this.getObjectMetadata(request, parsedObject); @@ -156,42 +108,6 @@ export class CoreQueryBuilderFactory { }; } - async update(request: Request): Promise { - const { object: parsedObject } = parseCorePath(request); - const objectMetadata = await this.getObjectMetadata(request, parsedObject); - - const depth = computeDepth(request); - - const { id } = parseCorePath(request); - - if (!id) { - throw new BadRequestException( - `update ${objectMetadata.objectMetadataMapItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataMapItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, - ); - } - - return { - query: this.updateQueryFactory.create(objectMetadata, depth), - variables: this.updateVariablesFactory.create(id, request), - }; - } - - async get(request: Request): Promise { - const { object: parsedObject } = parseCorePath(request); - const objectMetadata = await this.getObjectMetadata(request, parsedObject); - - const depth = computeDepth(request); - - const { id } = parseCorePath(request); - - return { - query: id - ? this.findOneQueryFactory.create(objectMetadata, depth) - : this.findManyQueryFactory.create(objectMetadata, depth), - variables: this.getVariablesFactory.create(id, request, objectMetadata), - }; - } - async findDuplicates(request: Request): Promise { const { object: parsedObject } = parseCorePath(request); const objectMetadata = await this.getObjectMetadata(request, parsedObject); diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/create-one-query.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/create-one-query.factory.ts deleted file mode 100644 index 219c09b71..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/create-one-query.factory.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils'; -import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; - -@Injectable() -export class CreateOneQueryFactory { - create( - objectMetadata: { - objectMetadataMaps: ObjectMetadataMaps; - objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; - }, - depth?: number, - ): string { - const objectNameSingular = capitalize( - objectMetadata.objectMetadataMapItem.nameSingular, - ); - - return ` - mutation Create${objectNameSingular}($data: ${objectNameSingular}CreateInput!) { - create${objectNameSingular}(data: $data) { - id - ${objectMetadata.objectMetadataMapItem.fields - .map((field) => - mapFieldMetadataToGraphqlQuery( - objectMetadata.objectMetadataMaps, - field, - depth, - ), - ) - .join('\n')} - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/delete-query.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/delete-query.factory.ts deleted file mode 100644 index d973859f1..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/delete-query.factory.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; - -@Injectable() -export class DeleteQueryFactory { - create(objectMetadataMapItem: ObjectMetadataItemWithFieldMaps): string { - const objectNameSingular = capitalize(objectMetadataMapItem.nameSingular); - - return ` - mutation Delete${objectNameSingular}($id: ID!) { - delete${objectNameSingular}(id: $id) { - id - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/delete-variables.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/delete-variables.factory.ts deleted file mode 100644 index 09c047360..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/delete-variables.factory.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type'; - -@Injectable() -export class DeleteVariablesFactory { - create(id: string): QueryVariables { - return { - id, - }; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/factories.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/factories.ts index 811f8ceeb..16f34a21a 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/factories.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/factories.ts @@ -1,28 +1,14 @@ import { CreateManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-many-query.factory'; -import { CreateOneQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-one-query.factory'; -import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory'; -import { DeleteQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/delete-query.factory'; -import { DeleteVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/delete-variables.factory'; import { FindDuplicatesQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-query.factory'; import { FindDuplicatesVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-variables.factory'; -import { FindManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-many-query.factory'; -import { FindOneQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-one-query.factory'; import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory'; -import { UpdateQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/update-query.factory'; -import { UpdateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/update-variables.factory'; +import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory'; import { inputFactories } from 'src/engine/api/rest/input-factories/factories'; export const coreQueryBuilderFactories = [ - DeleteQueryFactory, - CreateOneQueryFactory, CreateManyQueryFactory, - UpdateQueryFactory, - FindOneQueryFactory, - FindManyQueryFactory, FindDuplicatesQueryFactory, - DeleteVariablesFactory, CreateVariablesFactory, - UpdateVariablesFactory, GetVariablesFactory, FindDuplicatesVariablesFactory, ...inputFactories, diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/find-many-query.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/find-many-query.factory.ts deleted file mode 100644 index 64276f768..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/find-many-query.factory.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils'; -import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; - -@Injectable() -export class FindManyQueryFactory { - create( - objectMetadata: { - objectMetadataMaps: ObjectMetadataMaps; - objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; - }, - depth?: number, - ): string { - const objectNameSingular = capitalize( - objectMetadata.objectMetadataMapItem.nameSingular, - ); - const objectNamePlural = objectMetadata.objectMetadataMapItem.namePlural; - - return ` - query FindMany${capitalize(objectNamePlural)}( - $filter: ${objectNameSingular}FilterInput, - $orderBy: [${objectNameSingular}OrderByInput], - $startingAfter: String, - $endingBefore: String, - $first: Int, - $last: Int - ) { - ${objectNamePlural}( - filter: $filter, - orderBy: $orderBy, - first: $first, - last: $last, - after: $startingAfter, - before: $endingBefore - ) { - edges { - node { - id - ${objectMetadata.objectMetadataMapItem.fields - .map((field) => - mapFieldMetadataToGraphqlQuery( - objectMetadata.objectMetadataMaps, - field, - depth, - ), - ) - .join('\n')} - } - cursor - } - pageInfo { - hasNextPage - startCursor - endCursor - } - totalCount - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/find-one-query.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/find-one-query.factory.ts deleted file mode 100644 index cd275c311..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/find-one-query.factory.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils'; -import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; - -@Injectable() -export class FindOneQueryFactory { - create( - objectMetadata: { - objectMetadataMaps: ObjectMetadataMaps; - objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; - }, - depth?: number, - ): string { - const objectNameSingular = - objectMetadata.objectMetadataMapItem.nameSingular; - - return ` - query FindOne${capitalize(objectNameSingular)}( - $filter: ${capitalize(objectNameSingular)}FilterInput!, - ) { - ${objectNameSingular}(filter: $filter) { - id - ${objectMetadata.objectMetadataMapItem.fields - .map((field) => - mapFieldMetadataToGraphqlQuery( - objectMetadata.objectMetadataMaps, - field, - depth, - ), - ) - .join('\n')} - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/get-variables.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/get-variables.factory.ts index fe5331a72..3e768527a 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/get-variables.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/get-variables.factory.ts @@ -33,13 +33,15 @@ export class GetVariablesFactory { return { filter: { id: { eq: id } } }; } + const filter = this.filterInputFactory.create(request, objectMetadata); const limit = this.limitInputFactory.create(request); + const orderBy = this.orderByInputFactory.create(request, objectMetadata); const endingBefore = this.endingBeforeInputFactory.create(request); const startingAfter = this.startingAfterInputFactory.create(request); return { - filter: this.filterInputFactory.create(request, objectMetadata), - orderBy: this.orderByInputFactory.create(request, objectMetadata), + filter, + orderBy, first: !endingBefore ? limit : undefined, last: endingBefore ? limit : undefined, startingAfter, diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/update-query.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/update-query.factory.ts deleted file mode 100644 index 5997cf473..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/update-query.factory.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils'; -import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; - -@Injectable() -export class UpdateQueryFactory { - create( - objectMetadata: { - objectMetadataMaps: ObjectMetadataMaps; - objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; - }, - depth?: number, - ): string { - const objectNameSingular = - objectMetadata.objectMetadataMapItem.nameSingular; - - return ` - mutation Update${capitalize( - objectNameSingular, - )}($id: ID!, $data: ${capitalize(objectNameSingular)}UpdateInput!) { - update${capitalize(objectNameSingular)}(id: $id, data: $data) { - id - ${Object.values(objectMetadata.objectMetadataMapItem.fieldsById) - .map((field) => - mapFieldMetadataToGraphqlQuery( - objectMetadata.objectMetadataMaps, - field, - depth, - ), - ) - .join('\n')} - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/update-variables.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/update-variables.factory.ts deleted file mode 100644 index c911faa03..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/factories/update-variables.factory.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { Request } from 'express'; - -import { QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type'; - -@Injectable() -export class UpdateVariablesFactory { - create(id: string, request: Request): QueryVariables { - return { - id, - data: request.body, - }; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts b/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts index 9a4d9f4aa..403197775 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts @@ -1,197 +1,44 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Request } from 'express'; -import { capitalize, isDefined } from 'twenty-shared/utils'; +import { isDefined } from 'twenty-shared/utils'; -import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - -import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; -import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; -import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; -import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-delete-one.handler'; +import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler'; +import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; +import { RestApiGetOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-one.handler'; +import { RestApiGetManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-many.handler'; @Injectable() export class RestApiCoreServiceV2 { constructor( - private readonly coreQueryBuilderFactory: CoreQueryBuilderFactory, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - private readonly recordInputTransformerService: RecordInputTransformerService, - protected readonly apiEventEmitterService: ApiEventEmitterService, - private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService, + private readonly restApiDeleteOneHandler: RestApiDeleteOneHandler, + private readonly restApiCreateOneHandler: RestApiCreateOneHandler, + private readonly restApiUpdateOneHandler: RestApiUpdateOneHandler, + private readonly restApiGetOneHandler: RestApiGetOneHandler, + private readonly restApiGetManyHandler: RestApiGetManyHandler, ) {} async delete(request: Request) { - const { id: recordId } = parseCorePath(request); - - if (!recordId) { - throw new BadRequestException('Record ID not found'); - } - - const { objectMetadataNameSingular, objectMetadata, repository } = - await this.getRepositoryAndMetadataOrFail(request); - const recordToDelete = await repository.findOneOrFail({ - where: { id: recordId }, - }); - - await repository.delete(recordId); - - this.apiEventEmitterService.emitDestroyEvents( - [recordToDelete], - this.getAuthContextFromRequest(request), - objectMetadata.objectMetadataMapItem, - ); - - return this.formatResult('delete', objectMetadataNameSingular, { - id: recordToDelete.id, - }); + return await this.restApiDeleteOneHandler.handle(request); } async createOne(request: Request) { - const { objectMetadataNameSingular, objectMetadata, repository } = - await this.getRepositoryAndMetadataOrFail(request); - - const overriddenBody = await this.recordInputTransformerService.process({ - recordInput: request.body, - objectMetadataMapItem: objectMetadata.objectMetadataMapItem, - }); - - const recordExists = - isDefined(overriddenBody.id) && - (await repository.exists({ - where: { - id: overriddenBody.id, - }, - })); - - if (recordExists) { - throw new BadRequestException('Record already exists'); - } - - const createdRecord = await repository.save(overriddenBody); - - this.apiEventEmitterService.emitCreateEvents( - [createdRecord], - this.getAuthContextFromRequest(request), - objectMetadata.objectMetadataMapItem, - ); - - return this.formatResult( - 'create', - objectMetadataNameSingular, - createdRecord, - ); + return await this.restApiCreateOneHandler.handle(request); } async update(request: Request) { + return await this.restApiUpdateOneHandler.handle(request); + } + + async get(request: Request) { const { id: recordId } = parseCorePath(request); - if (!recordId) { - throw new BadRequestException('Record ID not found'); + if (isDefined(recordId)) { + return await this.restApiGetOneHandler.handle(request); + } else { + return await this.restApiGetManyHandler.handle(request); } - - const { objectMetadataNameSingular, objectMetadata, repository } = - await this.getRepositoryAndMetadataOrFail(request); - - const recordToUpdate = await repository.findOneOrFail({ - where: { id: recordId }, - }); - - const overriddenBody = await this.recordInputTransformerService.process({ - recordInput: request.body, - objectMetadataMapItem: objectMetadata.objectMetadataMapItem, - }); - - const updatedRecord = await repository.save({ - ...recordToUpdate, - ...overriddenBody, - }); - - this.apiEventEmitterService.emitUpdateEvents( - [recordToUpdate], - [updatedRecord], - Object.keys(request.body), - this.getAuthContextFromRequest(request), - objectMetadata.objectMetadataMapItem, - ); - - return this.formatResult( - 'update', - objectMetadataNameSingular, - updatedRecord, - ); - } - - private formatResult( - operation: 'delete' | 'create' | 'update' | 'find', - objectNameSingular: string, - data: T, - ) { - const result = { - data: { - [operation + capitalize(objectNameSingular)]: data, - }, - }; - - return result; - } - - private async getRepositoryAndMetadataOrFail(request: Request) { - const { workspace, apiKey, userWorkspaceId } = request; - const { object: parsedObject } = parseCorePath(request); - - const objectMetadata = await this.coreQueryBuilderFactory.getObjectMetadata( - request, - parsedObject, - ); - - if (!objectMetadata) { - throw new BadRequestException('Object metadata not found'); - } - - if (!workspace?.id) { - throw new BadRequestException('Workspace not found'); - } - - const dataSource = - await this.twentyORMGlobalManager.getDataSourceForWorkspace({ - workspaceId: workspace.id, - shouldFailIfMetadataNotFound: false, - }); - - const objectMetadataNameSingular = - objectMetadata.objectMetadataMapItem.nameSingular; - - const shouldBypassPermissionChecks = !!apiKey; - - const roleId = - await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId({ - workspaceId: workspace.id, - userWorkspaceId, - }); - - const repository = dataSource.getRepository( - objectMetadataNameSingular, - shouldBypassPermissionChecks, - roleId, - ); - - return { - objectMetadataNameSingular, - objectMetadata, - repository, - }; - } - - private getAuthContextFromRequest(request: Request): AuthContext { - return { - user: request.user, - workspace: request.workspace, - apiKey: request.apiKey, - workspaceMemberId: request.workspaceMemberId, - userWorkspaceId: request.userWorkspaceId, - }; } } diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts new file mode 100644 index 000000000..262858f52 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts @@ -0,0 +1,51 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; + +import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-delete-one.handler'; +import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler'; +import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; +import { RestApiGetOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-one.handler'; +import { RestApiGetManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-many.handler'; +import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; +import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; +import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; +import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module'; +import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller'; +import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories'; +import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller'; +import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service'; +import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service'; +import { RestApiService } from 'src/engine/api/rest/rest-api.service'; +import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; +import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; + +const restApiCoreResolvers = [ + RestApiDeleteOneHandler, + RestApiCreateOneHandler, + RestApiUpdateOneHandler, + RestApiGetOneHandler, + RestApiGetManyHandler, +]; + +@Module({ + imports: [ + CoreQueryBuilderModule, + WorkspaceCacheStorageModule, + AuthModule, + HttpModule, + TwentyORMModule, + RecordTransformerModule, + WorkspacePermissionsCacheModule, + ], + controllers: [RestApiCoreController, RestApiCoreBatchController], + providers: [ + RestApiService, + RestApiCoreService, + RestApiCoreServiceV2, + ApiEventEmitterService, + ...coreQueryBuilderFactories, + ...restApiCoreResolvers, + ], +}) +export class RestApiCoreModule {} diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.service.ts b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.service.ts index 4eaca7df5..496f8e2aa 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.service.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.service.ts @@ -15,36 +15,12 @@ export class RestApiCoreService { private readonly restApiService: RestApiService, ) {} - async get(request: Request) { - const data = await this.coreQueryBuilderFactory.get(request); - - return await this.restApiService.call(GraphqlApiType.CORE, request, data); - } - - async delete(request: Request) { - const data = await this.coreQueryBuilderFactory.delete(request); - - return await this.restApiService.call(GraphqlApiType.CORE, request, data); - } - - async createOne(request: Request) { - const data = await this.coreQueryBuilderFactory.createOne(request); - - return await this.restApiService.call(GraphqlApiType.CORE, request, data); - } - async createMany(request: Request) { const data = await this.coreQueryBuilderFactory.createMany(request); return await this.restApiService.call(GraphqlApiType.CORE, request, data); } - async update(request: Request) { - const data = await this.coreQueryBuilderFactory.update(request); - - return await this.restApiService.call(GraphqlApiType.CORE, request, data); - } - async findDuplicates(request: Request) { const data = await this.coreQueryBuilderFactory.findDuplicates(request); diff --git a/packages/twenty-server/src/engine/api/rest/core/types/query-variables.type.ts b/packages/twenty-server/src/engine/api/rest/core/types/query-variables.type.ts index 4c10c34a7..a50afeb80 100644 --- a/packages/twenty-server/src/engine/api/rest/core/types/query-variables.type.ts +++ b/packages/twenty-server/src/engine/api/rest/core/types/query-variables.type.ts @@ -1,9 +1,11 @@ +import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + export type QueryVariables = { id?: string; ids?: string[]; data?: object | null; filter?: object; - orderBy?: object; + orderBy?: ObjectRecordOrderBy; last?: number; first?: number; startingAfter?: string; diff --git a/packages/twenty-server/src/engine/api/rest/input-factories/depth-input.factory.ts b/packages/twenty-server/src/engine/api/rest/input-factories/depth-input.factory.ts new file mode 100644 index 000000000..d73c07de1 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-factories/depth-input.factory.ts @@ -0,0 +1,32 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +export const MAX_DEPTH = 2; + +export type Depth = 0 | 1 | 2; + +const ALLOWED_DEPTH_VALUES: Depth[] = [0, 1, MAX_DEPTH]; + +@Injectable() +export class DepthInputFactory { + create(request: Request): Depth { + if (!request.query.depth) { + return 0; + } + + const depth = +request.query.depth as Depth; + + if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) { + throw new BadRequestException( + `'depth=${ + request.query.depth + }' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join( + ', ', + )}`, + ); + } + + return depth; + } +} diff --git a/packages/twenty-server/src/engine/api/rest/input-factories/factories.ts b/packages/twenty-server/src/engine/api/rest/input-factories/factories.ts index bbe17eeca..56043dae8 100644 --- a/packages/twenty-server/src/engine/api/rest/input-factories/factories.ts +++ b/packages/twenty-server/src/engine/api/rest/input-factories/factories.ts @@ -3,11 +3,13 @@ import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/en import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory'; import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory'; import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory'; +import { DepthInputFactory } from 'src/engine/api/rest/input-factories/depth-input.factory'; export const inputFactories = [ - StartingAfterInputFactory, + DepthInputFactory, EndingBeforeInputFactory, + FilterInputFactory, LimitInputFactory, OrderByInputFactory, - FilterInputFactory, + StartingAfterInputFactory, ]; diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts index 72fcc6a69..acd1bb3b0 100644 --- a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts +++ b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts @@ -1,51 +1,23 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; -import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller'; -import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller'; -import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module'; -import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service'; -import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service'; -import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory'; -import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory'; -import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory'; import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module'; -import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller'; import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service'; -import { RestApiService } from 'src/engine/api/rest/rest-api.service'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; -import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; -import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module'; -import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; +import { RestApiCoreModule } from 'src/engine/api/rest/core/rest-api-core.module'; +import { RestApiService } from 'src/engine/api/rest/rest-api.service'; +import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller'; @Module({ imports: [ - CoreQueryBuilderModule, MetadataQueryBuilderModule, WorkspaceCacheStorageModule, AuthModule, HttpModule, - TwentyORMModule, - RecordTransformerModule, - WorkspacePermissionsCacheModule, + RestApiCoreModule, ], - controllers: [ - RestApiMetadataController, - RestApiCoreBatchController, - RestApiCoreController, - ], - providers: [ - RestApiMetadataService, - RestApiCoreService, - RestApiCoreServiceV2, - RestApiService, - StartingAfterInputFactory, - EndingBeforeInputFactory, - LimitInputFactory, - ApiEventEmitterService, - ], - exports: [RestApiMetadataService], + controllers: [RestApiMetadataController], + providers: [RestApiService, RestApiMetadataService], }) export class RestApiModule {} diff --git a/packages/twenty-server/src/engine/core-modules/cache-storage/services/cache-storage.service.ts b/packages/twenty-server/src/engine/core-modules/cache-storage/services/cache-storage.service.ts index 302afad98..6990137ad 100644 --- a/packages/twenty-server/src/engine/core-modules/cache-storage/services/cache-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/cache-storage/services/cache-storage.service.ts @@ -15,15 +15,15 @@ export class CacheStorageService { ) {} async get(key: string): Promise { - return this.cache.get(`${this.namespace}:${key}`); + return this.cache.get(this.getKey(key)); } async set(key: string, value: T, ttl?: Milliseconds) { - return this.cache.set(`${this.namespace}:${key}`, value, ttl); + return this.cache.set(this.getKey(key), value, ttl); } async del(key: string) { - return this.cache.del(`${this.namespace}:${key}`); + return this.cache.del(this.getKey(key)); } async setAdd(key: string, value: string[], ttl?: Milliseconds) { @@ -33,13 +33,13 @@ export class CacheStorageService { if (this.isRedisCache()) { await (this.cache as RedisCache).store.client.sAdd( - `${this.namespace}:${key}`, + this.getKey(key), value, ); if (ttl) { await (this.cache as RedisCache).store.client.expire( - `${this.namespace}:${key}`, + this.getKey(key), ttl / 1000, ); } @@ -65,7 +65,7 @@ export class CacheStorageService { async setPop(key: string, size = 1) { if (this.isRedisCache()) { return (this.cache as RedisCache).store.client.sPop( - `${this.namespace}:${key}`, + this.getKey(key), size, ); } @@ -84,7 +84,7 @@ export class CacheStorageService { async getSetLength(key: string) { if (this.isRedisCache()) { return await (this.cache as RedisCache).store.client.sCard( - `${this.namespace}:${key}`, + this.getKey(key), ); } @@ -125,4 +125,14 @@ export class CacheStorageService { private isRedisCache() { return (this.cache.store as any)?.name === 'redis'; } + + private getKey(key: string) { + const formattedKey = `${this.namespace}:${key}`; + + if (process.env.NODE_ENV === 'test') { + return `integration-tests:${formattedKey}`; + } + + return formattedKey; + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts index f75613e11..138d2326b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts @@ -24,8 +24,8 @@ type CacheResult = { data: U; }; -const USER_WORKSPACE_ROLE_MAP = 'User workspace role map'; -const ROLES_PERMISSIONS = 'Roles permissions'; +export const USER_WORKSPACE_ROLE_MAP = 'User workspace role map'; +export const ROLES_PERMISSIONS = 'Roles permissions'; @Injectable() export class WorkspacePermissionsCacheService { diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts index 2ad36966d..fb639b433 100644 --- a/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts +++ b/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts @@ -12,7 +12,10 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data- import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service'; -import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; +import { + ROLES_PERMISSIONS, + WorkspacePermissionsCacheService, +} from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { TwentyORMException, @@ -229,7 +232,7 @@ export class WorkspaceDatasourceFactory { workspaceId, ignoreLock: true, }), - cachedEntityName: 'Roles permissions', + cachedEntityName: ROLES_PERMISSIONS, exceptionCode: TwentyORMExceptionCode.ROLES_PERMISSIONS_VERSION_NOT_FOUND, }); } diff --git a/packages/twenty-server/test/integration/constants/initial-person-data.constants.ts b/packages/twenty-server/test/integration/constants/initial-person-data.constants.ts deleted file mode 100644 index f0c1fa922..000000000 --- a/packages/twenty-server/test/integration/constants/initial-person-data.constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { PERSON_2_ID } from 'test/integration/constants/mock-person-ids.constants'; - -export const INITIAL_PERSON_DATA = { - id: PERSON_2_ID, - name: { - firstName: 'Testing', - lastName: 'User', - }, - emails: { - primaryEmail: 'test8@user.com', - additionalEmails: ['user8@example.com'], - }, - city: 'New York', - jobTitle: 'Manager', - companyId: '20202020-0713-40a5-8216-82802401d33e', -}; diff --git a/packages/twenty-server/test/integration/constants/mock-person-ids.constants.ts b/packages/twenty-server/test/integration/constants/mock-person-ids.constants.ts deleted file mode 100644 index b7816bd60..000000000 --- a/packages/twenty-server/test/integration/constants/mock-person-ids.constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const PERSON_1_ID = '777a8457-eb2d-40ac-a707-551b615b6987'; -export const PERSON_2_ID = '777a8457-eb2d-40ac-a707-551b615b6988'; -export const PERSON_3_ID = '777a8457-eb2d-40ac-a707-551b615b6989'; -export const NOT_EXISTING_PERSON_ID = '777a8457-eb2d-40ac-a707-551b615b6990'; diff --git a/packages/twenty-server/test/integration/constants/test-company-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-company-ids.constants.ts new file mode 100644 index 000000000..8111e961f --- /dev/null +++ b/packages/twenty-server/test/integration/constants/test-company-ids.constants.ts @@ -0,0 +1 @@ +export const TEST_COMPANY_1_ID = '525c282e-030a-4a3e-90a0-d8aad0d33a93'; diff --git a/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts new file mode 100644 index 000000000..9d1805dee --- /dev/null +++ b/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts @@ -0,0 +1,6 @@ +export const TEST_PERSON_1_ID = '777a8457-eb2d-40ac-a707-551b615b6980'; +export const TEST_PERSON_2_ID = '777a8457-eb2d-40ac-a707-551b615b6981'; +export const TEST_PERSON_3_ID = '777a8457-eb2d-40ac-a707-551b615b6982'; +export const TEST_PERSON_4_ID = '777a8457-eb2d-40ac-a707-551b615b6983'; +export const NOT_EXISTING_TEST_PERSON_ID = + '777a8457-eb2d-40ac-a707-551b615b6990'; diff --git a/packages/twenty-server/test/integration/constants/test-primary-link-url.constant.ts b/packages/twenty-server/test/integration/constants/test-primary-link-url.constant.ts new file mode 100644 index 000000000..a577aefc0 --- /dev/null +++ b/packages/twenty-server/test/integration/constants/test-primary-link-url.constant.ts @@ -0,0 +1 @@ +export const TEST_PRIMARY_LINK_URL = 'http://test/'; diff --git a/packages/twenty-server/test/integration/graphql/suites/all-people-resolvers.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/all-people-resolvers.integration-spec.ts index b1ce7b7bb..84ba1b678 100644 --- a/packages/twenty-server/test/integration/graphql/suites/all-people-resolvers.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/all-people-resolvers.integration-spec.ts @@ -1,8 +1,8 @@ import { - PERSON_1_ID, - PERSON_2_ID, - PERSON_3_ID, -} from 'test/integration/constants/mock-person-ids.constants'; + TEST_PERSON_1_ID, + TEST_PERSON_2_ID, + TEST_PERSON_3_ID, +} from 'test/integration/constants/test-person-ids.constants'; import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; @@ -16,22 +16,27 @@ import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graph import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util'; import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util'; import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; describe('people resolvers (integration)', () => { + beforeAll(async () => { + await deleteAllRecords('person'); + }); + it('1. should create and return people', async () => { - const personCity1 = generateRecordName(PERSON_1_ID); - const personCity2 = generateRecordName(PERSON_2_ID); + const personCity1 = generateRecordName(TEST_PERSON_1_ID); + const personCity2 = generateRecordName(TEST_PERSON_2_ID); const graphqlOperation = createManyOperationFactory({ objectMetadataSingularName: 'person', objectMetadataPluralName: 'people', gqlFields: PERSON_GQL_FIELDS, data: [ { - id: PERSON_1_ID, + id: TEST_PERSON_1_ID, city: personCity1, }, { - id: PERSON_2_ID, + id: TEST_PERSON_2_ID, city: personCity2, }, ], @@ -57,13 +62,13 @@ describe('people resolvers (integration)', () => { }); it('1b. should create and return one person', async () => { - const personCity3 = generateRecordName(PERSON_3_ID); + const personCity3 = generateRecordName(TEST_PERSON_3_ID); const graphqlOperation = createOneOperationFactory({ objectMetadataSingularName: 'person', gqlFields: PERSON_GQL_FIELDS, data: { - id: PERSON_3_ID, + id: TEST_PERSON_3_ID, city: personCity3, }, }); @@ -121,7 +126,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - eq: PERSON_3_ID, + eq: TEST_PERSON_3_ID, }, }, }); @@ -152,7 +157,7 @@ describe('people resolvers (integration)', () => { }, filter: { id: { - in: [PERSON_1_ID, PERSON_2_ID], + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], }, }, }); @@ -175,7 +180,7 @@ describe('people resolvers (integration)', () => { data: { city: 'New City', }, - recordId: PERSON_3_ID, + recordId: TEST_PERSON_3_ID, }); const response = await makeGraphqlAPIRequest(graphqlOperation); @@ -225,7 +230,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - in: [PERSON_1_ID, PERSON_2_ID], + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], }, }, }); @@ -245,7 +250,7 @@ describe('people resolvers (integration)', () => { const graphqlOperation = deleteOneOperationFactory({ objectMetadataSingularName: 'person', gqlFields: PERSON_GQL_FIELDS, - recordId: PERSON_3_ID, + recordId: TEST_PERSON_3_ID, }); const response = await makeGraphqlAPIRequest(graphqlOperation); @@ -260,7 +265,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - in: [PERSON_1_ID, PERSON_2_ID], + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], }, }, }); @@ -276,7 +281,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - eq: PERSON_3_ID, + eq: TEST_PERSON_3_ID, }, }, }); @@ -293,7 +298,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - in: [PERSON_1_ID, PERSON_2_ID], + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], }, not: { deletedAt: { @@ -314,7 +319,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - eq: PERSON_3_ID, + eq: TEST_PERSON_3_ID, }, not: { deletedAt: { @@ -326,7 +331,7 @@ describe('people resolvers (integration)', () => { const response = await makeGraphqlAPIRequest(graphqlOperation); - expect(response.body.data.person.id).toEqual(PERSON_3_ID); + expect(response.body.data.person.id).toEqual(TEST_PERSON_3_ID); }); it('8. should destroy many people', async () => { @@ -336,7 +341,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - in: [PERSON_1_ID, PERSON_2_ID], + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], }, }, }); @@ -350,7 +355,7 @@ describe('people resolvers (integration)', () => { const graphqlOperation = destroyOneOperationFactory({ objectMetadataSingularName: 'person', gqlFields: PERSON_GQL_FIELDS, - recordId: PERSON_3_ID, + recordId: TEST_PERSON_3_ID, }); const destroyPeopleResponse = await makeGraphqlAPIRequest(graphqlOperation); @@ -365,7 +370,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - in: [PERSON_1_ID, PERSON_2_ID], + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], }, not: { deletedAt: { @@ -386,7 +391,7 @@ describe('people resolvers (integration)', () => { gqlFields: PERSON_GQL_FIELDS, filter: { id: { - eq: PERSON_3_ID, + eq: TEST_PERSON_3_ID, }, not: { deletedAt: { diff --git a/packages/twenty-server/test/integration/graphql/suites/auth.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth.integration-spec.ts index a8b9ac28a..ed8dbda31 100644 --- a/packages/twenty-server/test/integration/graphql/suites/auth.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/auth.integration-spec.ts @@ -8,7 +8,7 @@ const ORIGIN = new URL(SERVER_URL); ORIGIN.hostname = process.env.IS_MULTIWORKSPACE_ENABLED === 'true' - ? `acme.${ORIGIN.hostname}` + ? `apple.${ORIGIN.hostname}` : ORIGIN.hostname; const auth = { diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/data-model.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/data-model.integration-spec.ts index 7d7ee7c05..b1101819e 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/data-model.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/data-model.integration-spec.ts @@ -14,8 +14,6 @@ import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors. import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; describe('datamodel permissions', () => { - beforeAll(async () => {}); - describe('fieldMetadata', () => { let listingObjectId = ''; let testFieldId = ''; diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-create-one.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-create-one.integration-spec.ts index df51ca04f..19a8cff82 100644 --- a/packages/twenty-server/test/integration/rest/suites/rest-api-core-create-one.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-create-one.integration-spec.ts @@ -1,21 +1,31 @@ -import { PERSON_1_ID } from 'test/integration/constants/mock-person-ids.constants'; +import { TEST_PERSON_1_ID } from 'test/integration/constants/test-person-ids.constants'; import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; +import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant'; describe('Core REST API Create One endpoint', () => { - beforeAll( - async () => - await makeRestAPIRequest({ - method: 'delete', - path: `/people/${PERSON_1_ID}`, - }), - ); + beforeEach(async () => { + await deleteAllRecords('person'); + await makeRestAPIRequest({ + method: 'post', + path: '/companies', + body: { + id: TEST_COMPANY_1_ID, + domainName: { + primaryLinkUrl: TEST_PRIMARY_LINK_URL, + }, + }, + }); + }); it('should create a new person', async () => { - const personCity = generateRecordName(PERSON_1_ID); + const personCity = generateRecordName(TEST_PERSON_1_ID); const requestBody = { - id: PERSON_1_ID, + id: TEST_PERSON_1_ID, city: personCity, + companyId: TEST_COMPANY_1_ID, }; await makeRestAPIRequest({ @@ -27,18 +37,97 @@ describe('Core REST API Create One endpoint', () => { .expect((res) => { const createdPerson = res.body.data.createPerson; - expect(createdPerson.id).toBe(PERSON_1_ID); + expect(createdPerson.id).toBe(TEST_PERSON_1_ID); expect(createdPerson.city).toBe(personCity); }); }); - it('should return a BadRequestException when trying to create a person with an existing ID', async () => { - const personCity = generateRecordName(PERSON_1_ID); + it('should support depth 0 parameter', async () => { + const personCity = generateRecordName(TEST_PERSON_1_ID); const requestBody = { - id: PERSON_1_ID, + id: TEST_PERSON_1_ID, + city: personCity, + companyId: TEST_COMPANY_1_ID, + }; + + await makeRestAPIRequest({ + method: 'post', + path: `/people?depth=0`, + body: requestBody, + }) + .expect(201) + .expect((res) => { + const createdPerson = res.body.data.createPerson; + + expect(createdPerson.companyId).toBeDefined(); + expect(createdPerson.company).not.toBeDefined(); + }); + }); + + it('should support depth 1 parameter', async () => { + const personCity = generateRecordName(TEST_PERSON_1_ID); + const requestBody = { + id: TEST_PERSON_1_ID, + city: personCity, + companyId: TEST_COMPANY_1_ID, + }; + + await makeRestAPIRequest({ + method: 'post', + path: `/people?depth=1`, + body: requestBody, + }) + .expect(201) + .expect((res) => { + const createdPerson = res.body.data.createPerson; + + expect(createdPerson.company).toBeDefined(); + expect(createdPerson.company.domainName.primaryLinkUrl).toBe( + TEST_PRIMARY_LINK_URL, + ); + expect(createdPerson.company.people).not.toBeDefined(); + }); + }); + + it('should support depth 2 parameter', async () => { + const personCity = generateRecordName(TEST_PERSON_1_ID); + const requestBody = { + id: TEST_PERSON_1_ID, + city: personCity, + companyId: TEST_COMPANY_1_ID, + }; + + await makeRestAPIRequest({ + method: 'post', + path: `/people?depth=2`, + body: requestBody, + }) + .expect(201) + .expect((res) => { + const createdPerson = res.body.data.createPerson; + + expect(createdPerson.company.people).toBeDefined(); + const depth2Person = createdPerson.company.people.find( + (p) => p.id === createdPerson.id, + ); + + expect(depth2Person).toBeDefined(); + }); + }); + + it('should return a BadRequestException when trying to create a person with an existing ID', async () => { + const personCity = generateRecordName(TEST_PERSON_1_ID); + const requestBody = { + id: TEST_PERSON_1_ID, city: personCity, }; + await makeRestAPIRequest({ + method: 'post', + path: `/people`, + body: requestBody, + }); + await makeRestAPIRequest({ method: 'post', path: `/people`, diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-delete.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-delete.integration-spec.ts index d79c472a9..36c105e53 100644 --- a/packages/twenty-server/test/integration/rest/suites/rest-api-core-delete.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-delete.integration-spec.ts @@ -1,34 +1,59 @@ import { - NOT_EXISTING_PERSON_ID, - PERSON_1_ID, -} from 'test/integration/constants/mock-person-ids.constants'; + NOT_EXISTING_TEST_PERSON_ID, + TEST_PERSON_1_ID, +} from 'test/integration/constants/test-person-ids.constants'; import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; describe('Core REST API Delete One endpoint', () => { - beforeAll( - async () => - await makeRestAPIRequest({ - method: 'post', - path: `/people`, - body: { - id: PERSON_1_ID, - }, - }), - ); + beforeAll(async () => { + await deleteAllRecords('person'); + }); + + beforeEach(async () => { + await makeRestAPIRequest({ + method: 'post', + path: `/people`, + body: { + id: TEST_PERSON_1_ID, + }, + }); + }); it('should delete one person', async () => { await makeRestAPIRequest({ method: 'delete', - path: `/people/${PERSON_1_ID}`, + path: `/people/${TEST_PERSON_1_ID}`, }) .expect(200) - .expect((res) => expect(res.body.data.deletePerson.id).toBe(PERSON_1_ID)); + .expect((res) => + expect(res.body.data.deletePerson).toEqual({ id: TEST_PERSON_1_ID }), + ); + }); + + it('should delete one person with favorite', async () => { + await makeRestAPIRequest({ + method: 'post', + path: `/favorites`, + body: { + personId: TEST_PERSON_1_ID, + }, + }); + + await makeRestAPIRequest({ + method: 'delete', + path: `/people/${TEST_PERSON_1_ID}`, + }) + .expect(200) + .expect((res) => + expect(res.body.data.deletePerson).toEqual({ id: TEST_PERSON_1_ID }), + ); }); it('should return a EntityNotFoundError when trying to delete a non-existing person', async () => { await makeRestAPIRequest({ method: 'delete', - path: `/people/${NOT_EXISTING_PERSON_ID}`, + path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`, }) .expect(400) .expect((res) => { diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts new file mode 100644 index 000000000..d63c9ccd1 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts @@ -0,0 +1,289 @@ +import { + TEST_PERSON_1_ID, + TEST_PERSON_2_ID, + TEST_PERSON_3_ID, + TEST_PERSON_4_ID, +} from 'test/integration/constants/test-person-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; +import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant'; + +describe('Core REST API Find Many endpoint', () => { + const testPersonIds = [ + TEST_PERSON_1_ID, + TEST_PERSON_2_ID, + TEST_PERSON_3_ID, + TEST_PERSON_4_ID, + ]; + const testPersonCities: Record = {}; + + beforeAll(async () => { + await deleteAllRecords('person'); + + await makeRestAPIRequest({ + method: 'post', + path: '/companies', + body: { + id: TEST_COMPANY_1_ID, + domainName: { + primaryLinkUrl: TEST_PRIMARY_LINK_URL, + }, + }, + }); + + let index = 0; + + for (const personId of testPersonIds) { + const city = generateRecordName(personId); + + testPersonCities[personId] = city; + + await makeRestAPIRequest({ + method: 'post', + path: '/people', + body: { + id: personId, + city: city, + position: index, + companyId: TEST_COMPANY_1_ID, + }, + }); + index++; + } + }); + + it('should retrieve all people with pagination metadata', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people', + }).expect(200); + + const people = response.body.data.people; + const pageInfo = response.body.pageInfo; + const totalCount = response.body.totalCount; + + expect(people).not.toBeNull(); + expect(Array.isArray(people)).toBe(true); + expect(people.length).toBeGreaterThanOrEqual(testPersonIds.length); + + // Check that our test people are included in the results + for (const personId of testPersonIds) { + const person = people.find((p) => p.id === personId); + + expect(person).toBeDefined(); + expect(person.city).toBe(testPersonCities[personId]); + } + + // Check pagination metadata + expect(pageInfo).toBeDefined(); + expect(pageInfo.startCursor).toBeDefined(); + expect(pageInfo.endCursor).toBeDefined(); + expect(typeof totalCount).toBe('number'); + expect(totalCount).toEqual(testPersonIds.length); + expect(response.body.pageInfo.hasNextPage).toBe(false); + }); + + it('should limit results based on the limit parameter', async () => { + const limit = testPersonIds.length - 1; + const response = await makeRestAPIRequest({ + method: 'get', + path: `/people?limit=${limit}`, + }).expect(200); + + const people = response.body.data.people; + + expect(people).not.toBeNull(); + expect(Array.isArray(people)).toBe(true); + expect(people.length).toEqual(limit); + expect(response.body.totalCount).toEqual(testPersonIds.length); + expect(response.body.pageInfo.hasNextPage).toBe(true); + }); + + it('should return filtered totalCount', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/people?filter=position[lte]:1`, + }).expect(200); + + const people = response.body.data.people; + + expect(people).not.toBeNull(); + expect(Array.isArray(people)).toBe(true); + expect(people.length).toEqual(2); + expect(response.body.totalCount).toEqual(2); + expect(response.body.pageInfo.hasNextPage).toBe(false); + }); + + it('should filter results based on filter parameters', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people?filter=position[lt]:2', + }).expect(200); + + const filteredPeople = response.body.data.people; + + expect(filteredPeople).toBeDefined(); + expect(Array.isArray(filteredPeople)).toBe(true); + expect(filteredPeople.length).toBe(2); + }); + + it('should support cursor-based pagination with starting_after', async () => { + const initialResponse = await makeRestAPIRequest({ + method: 'get', + path: '/people?limit=2', + }).expect(200); + + const people = initialResponse.body.data.people; + const startCursor = initialResponse.body.pageInfo.startCursor; + + expect(people).toBeDefined(); + expect(people.length).toBe(2); + expect(startCursor).toBeDefined(); + + const nextPageResponse = await makeRestAPIRequest({ + method: 'get', + path: `/people?starting_after=${startCursor}&limit=1`, + }).expect(200); + + const nextPagePeople = nextPageResponse.body.data.people; + + expect(nextPagePeople).toBeDefined(); + expect(nextPagePeople.length).toBe(1); + expect(nextPagePeople[0].id).toBe(people[1].id); + }); + + it('should support cursor-based pagination with ending_before', async () => { + const initialResponse = await makeRestAPIRequest({ + method: 'get', + path: '/people?limit=4', + }).expect(200); + + const people = initialResponse.body.data.people; + const endCursor = initialResponse.body.pageInfo.endCursor; + + expect(people).toBeDefined(); + expect(people.length).toBe(4); + expect(endCursor).toBeDefined(); + + const nextPageResponse = await makeRestAPIRequest({ + method: 'get', + path: `/people?ending_before=${endCursor}&limit=2`, + }).expect(200); + + const nextPagePeople = nextPageResponse.body.data.people; + + expect(nextPagePeople).toBeDefined(); + expect(nextPagePeople.length).toBe(2); + expect(nextPagePeople[0].id).toBe(people[1].id); + expect(nextPagePeople[1].id).toBe(people[2].id); + }); + + it('should support ordering Asc of results', async () => { + const ascResponse = await makeRestAPIRequest({ + method: 'get', + path: '/people?order_by=position[AscNullsLast]', + }).expect(200); + + const ascPeople = ascResponse.body.data.people; + + expect(ascPeople).toEqual( + [...ascPeople].sort((a, b) => a.position - b.position), + ); + }); + + it('should support ordering Desc of results', async () => { + const descResponse = await makeRestAPIRequest({ + method: 'get', + path: '/people?order_by=position[DescNullsLast]', + }).expect(200); + + const descPeople = descResponse.body.data.people; + + expect(descPeople).toEqual( + [...descPeople].sort((a, b) => -(a.position - b.position)), + ); + }); + + it('should handle invalid cursor gracefully', async () => { + await makeRestAPIRequest({ + method: 'get', + path: '/people?starting_after=invalid-cursor', + }) + .expect(400) + .expect((res) => { + expect(res.body.error).toBe('BadRequestException'); + expect(res.body.messages[0]).toContain('Invalid cursor'); + }); + }); + + it('should combine filtering, ordering, and pagination', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people?filter=position[gt]:0&order_by=city[AscNullsFirst]&limit=2', + }).expect(200); + + const people = response.body.data.people; + + const pageInfo = response.body.pageInfo; + + expect(people).toBeDefined(); + expect(people.length).toBeLessThanOrEqual(2); + expect(pageInfo).toBeDefined(); + + expect(people).toEqual([...people].sort((a, b) => a.city - b.city)); + }); + + it('should support depth 0 parameter', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people?depth=0', + }).expect(200); + + const people = response.body.data.people; + + expect(people).toBeDefined(); + + const person = people[0]; + + expect(person).toBeDefined(); + expect(person.companyId).toBeDefined(); + expect(person.company).not.toBeDefined(); + }); + + it('should support depth 1 parameter', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people?depth=1', + }).expect(200); + + const people = response.body.data.people; + + const person = people[0]; + + expect(person.company).toBeDefined(); + expect(person.company.domainName.primaryLinkUrl).toBe( + TEST_PRIMARY_LINK_URL, + ); + + expect(person.company.people).not.toBeDefined(); + }); + + it('should support depth 2 parameter', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/people?depth=2', + }).expect(200); + + const people = response.body.data.people; + + const person = people[0]; + + expect(person.company.people).toBeDefined(); + + const depth2Person = person.company.people.find((p) => p.id === person.id); + + expect(depth2Person).toBeDefined(); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-one.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-one.integration-spec.ts new file mode 100644 index 000000000..73c7390f7 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-one.integration-spec.ts @@ -0,0 +1,119 @@ +import { + NOT_EXISTING_TEST_PERSON_ID, + TEST_PERSON_1_ID, +} from 'test/integration/constants/test-person-ids.constants'; +import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; +import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant'; + +describe('Core REST API Find One endpoint', () => { + let personCity: string; + + beforeAll(async () => { + await deleteAllRecords('person'); + await deleteAllRecords('company'); + + personCity = generateRecordName(TEST_PERSON_1_ID); + + await makeRestAPIRequest({ + method: 'post', + path: '/companies', + body: { + id: TEST_COMPANY_1_ID, + domainName: { + primaryLinkUrl: TEST_PRIMARY_LINK_URL, + }, + }, + }); + + await makeRestAPIRequest({ + method: 'post', + path: '/people', + body: { + id: TEST_PERSON_1_ID, + city: personCity, + companyId: TEST_COMPANY_1_ID, + }, + }); + }); + + it('should retrieve a person by ID', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/people/${TEST_PERSON_1_ID}`, + }) + .expect(200) + .expect((res) => { + const person = res.body.data.person; + + expect(person).not.toBeNull(); + expect(person.id).toBe(TEST_PERSON_1_ID); + expect(person.city).toBe(personCity); + }); + }); + + it('should return 400 error when trying to retrieve a non-existing person', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`, + }) + .expect(400) + .expect((res) => { + expect(res.body.messages[0]).toContain('Record not found'); + expect(res.body.error).toBe('BadRequestException'); + }); + }); + + it('should support depth 0 parameter', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/people/${TEST_PERSON_1_ID}?depth=0`, + }) + .expect(200) + .expect((res) => { + const person = res.body.data.person; + + expect(person).toBeDefined(); + expect(person.companyId).toBeDefined(); + expect(person.company).not.toBeDefined(); + }); + }); + + it('should support depth 1 parameter', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/people/${TEST_PERSON_1_ID}?depth=1`, + }) + .expect(200) + .expect((res) => { + const person = res.body.data.person; + + expect(person.company).toBeDefined(); + expect(person.company.domainName.primaryLinkUrl).toBe( + TEST_PRIMARY_LINK_URL, + ); + expect(person.company.people).not.toBeDefined(); + }); + }); + + it('should support depth 2 parameter', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/people/${TEST_PERSON_1_ID}?depth=2`, + }) + .expect(200) + .expect((res) => { + const person = res.body.data.person; + + expect(person.company.people).toBeDefined(); + + const depth2Person = person.company.people.find( + (p) => p.id === person.id, + ); + + expect(depth2Person).toBeDefined(); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-update.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-update.integration-spec.ts index fdff0713c..b9c81255d 100644 --- a/packages/twenty-server/test/integration/rest/suites/rest-api-core-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-update.integration-spec.ts @@ -1,48 +1,59 @@ import { - NOT_EXISTING_PERSON_ID, - PERSON_1_ID, -} from 'test/integration/constants/mock-person-ids.constants'; + NOT_EXISTING_TEST_PERSON_ID, + TEST_PERSON_1_ID, +} from 'test/integration/constants/test-person-ids.constants'; import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; +import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; +import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant'; describe('Core REST API Update One endpoint', () => { + const updatedData = { + name: { + firstName: 'Updated', + lastName: 'Person', + }, + emails: { + primaryEmail: 'updated@example.com', + additionalEmails: ['extra@example.com'], + }, + city: generateRecordName(TEST_PERSON_1_ID), + }; + beforeAll(async () => { + await deleteAllRecords('person'); await makeRestAPIRequest({ - method: 'delete', - path: `/people/${PERSON_1_ID}`, + method: 'post', + path: '/companies', + body: { + id: TEST_COMPANY_1_ID, + domainName: { + primaryLinkUrl: TEST_PRIMARY_LINK_URL, + }, + }, }); await makeRestAPIRequest({ method: 'post', path: `/people`, body: { - id: PERSON_1_ID, + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, }, }); }); it('should update an existing person (name, emails, and city)', async () => { - const updatedData = { - name: { - firstName: 'Updated', - lastName: 'Person', - }, - emails: { - primaryEmail: 'updated@example.com', - additionalEmails: ['extra@example.com'], - }, - city: generateRecordName(PERSON_1_ID), - }; - await makeRestAPIRequest({ method: 'patch', - path: `/people/${PERSON_1_ID}`, + path: `/people/${TEST_PERSON_1_ID}`, body: updatedData, }) .expect(200) .expect((res) => { const updatedPerson = res.body.data.updatePerson; - expect(updatedPerson.id).toBe(PERSON_1_ID); + expect(updatedPerson.id).toBe(TEST_PERSON_1_ID); expect(updatedPerson.name.firstName).toBe(updatedData.name.firstName); expect(updatedPerson.name.lastName).toBe(updatedData.name.lastName); expect(updatedPerson.emails.primaryEmail).toBe( @@ -54,14 +65,67 @@ describe('Core REST API Update One endpoint', () => { expect(updatedPerson.city).toBe(updatedData.city); expect(updatedPerson.jobTitle).toBe(''); - expect(updatedPerson.companyId).toBe(null); + expect(updatedPerson.companyId).toBe(TEST_COMPANY_1_ID); + }); + }); + + it('should support depth 0 parameter', async () => { + await makeRestAPIRequest({ + method: 'patch', + path: `/people/${TEST_PERSON_1_ID}?depth=0`, + body: updatedData, + }) + .expect(200) + .expect((res) => { + const updatedPerson = res.body.data.updatePerson; + + expect(updatedPerson.companyId).toBeDefined(); + expect(updatedPerson.company).not.toBeDefined(); + }); + }); + + it('should support depth 1 parameter', async () => { + await makeRestAPIRequest({ + method: 'patch', + path: `/people/${TEST_PERSON_1_ID}?depth=1`, + body: updatedData, + }) + .expect(200) + .expect((res) => { + const updatedPerson = res.body.data.updatePerson; + + expect(updatedPerson.company).toBeDefined(); + expect(updatedPerson.company.domainName.primaryLinkUrl).toBe( + TEST_PRIMARY_LINK_URL, + ); + expect(updatedPerson.company.people).not.toBeDefined(); + }); + }); + + it('should support depth 2 parameter', async () => { + await makeRestAPIRequest({ + method: 'patch', + path: `/people/${TEST_PERSON_1_ID}?depth=2`, + body: updatedData, + }) + .expect(200) + .expect((res) => { + const updatedPerson = res.body.data.updatePerson; + + expect(updatedPerson.company.people).toBeDefined(); + + const depth2Person = updatedPerson.company.people.find( + (p) => p.id === updatedPerson.id, + ); + + expect(depth2Person).toBeDefined(); }); }); it('should return a EntityNotFoundError when trying to update a non-existing person', async () => { await makeRestAPIRequest({ method: 'patch', - path: `/people/${NOT_EXISTING_PERSON_ID}`, + path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`, }) .expect(400) .expect((res) => { diff --git a/packages/twenty-server/test/integration/utils/delete-all-records.ts b/packages/twenty-server/test/integration/utils/delete-all-records.ts new file mode 100644 index 000000000..f03a1670e --- /dev/null +++ b/packages/twenty-server/test/integration/utils/delete-all-records.ts @@ -0,0 +1,11 @@ +const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5'; + +export const deleteAllRecords = async (objectNameSingular: string) => { + try { + await global.testDataSource.query( + `DELETE from "${TEST_SCHEMA_NAME}"."${objectNameSingular}"`, + ); + } catch { + /* empty */ + } +}; diff --git a/packages/twenty-server/test/integration/utils/setup-test.ts b/packages/twenty-server/test/integration/utils/setup-test.ts index bc206be68..0e0af19e4 100644 --- a/packages/twenty-server/test/integration/utils/setup-test.ts +++ b/packages/twenty-server/test/integration/utils/setup-test.ts @@ -1,6 +1,8 @@ import { JestConfigWithTsJest } from 'ts-jest'; import 'tsconfig-paths/register'; +import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource'; + import { createApp } from './create-app'; export default async (_, projectConfig: JestConfigWithTsJest) => { @@ -10,7 +12,10 @@ export default async (_, projectConfig: JestConfigWithTsJest) => { throw new Error('No globals found in project config'); } + await rawDataSource.initialize(); + await app.listen(projectConfig.globals.APP_PORT); global.app = app; + global.testDataSource = rawDataSource; }; diff --git a/packages/twenty-server/test/integration/utils/teardown-test.ts b/packages/twenty-server/test/integration/utils/teardown-test.ts index 8cc1946d5..72549ea13 100644 --- a/packages/twenty-server/test/integration/utils/teardown-test.ts +++ b/packages/twenty-server/test/integration/utils/teardown-test.ts @@ -1,5 +1,6 @@ import 'tsconfig-paths/register'; export default async () => { + global.testDataSource.destroy(); global.app.close(); };