diff --git a/.github/workflows/ci-breaking-changes.yaml b/.github/workflows/ci-breaking-changes.yaml index ba4c4b1b69678..33040d2b7223a 100644 --- a/.github/workflows/ci-breaking-changes.yaml +++ b/.github/workflows/ci-breaking-changes.yaml @@ -55,7 +55,7 @@ jobs: ports: - 6379:6379 clickhouse: - image: clickhouse/clickhouse-server:latest + image: clickhouse/clickhouse-server:25.8.8 env: CLICKHOUSE_PASSWORD: clickhousePassword CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty" diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index f6f0969a21c20..694fdf51afa41 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -191,7 +191,7 @@ jobs: ports: - 6379:6379 clickhouse: - image: clickhouse/clickhouse-server:latest + image: clickhouse/clickhouse-server:25.8.8 env: CLICKHOUSE_PASSWORD: clickhousePassword CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty" diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 4a37d3de0dc72..dcb326593b938 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1193,6 +1193,7 @@ export enum FeatureFlagKey { IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED', IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED', + IS_COMMON_API_ENABLED = 'IS_COMMON_API_ENABLED', IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED', diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 4e7ac5bdbf752..887a48a17dd70 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1157,6 +1157,7 @@ export enum FeatureFlagKey { IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED', IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED', + IS_COMMON_API_ENABLED = 'IS_COMMON_API_ENABLED', IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED', diff --git a/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-arg-handlers.ts b/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-arg-handlers.ts new file mode 100644 index 0000000000000..7b3b26e558a59 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-arg-handlers.ts @@ -0,0 +1,3 @@ +import { CommonSelectedFieldsHandler } from 'src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler'; + +export const CommonArgsHandlers = [CommonSelectedFieldsHandler]; diff --git a/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler.ts b/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler.ts new file mode 100644 index 0000000000000..aae9985d9e316 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler.ts @@ -0,0 +1,140 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; +import { + Depth, + MAX_DEPTH, +} from 'src/engine/api/rest/input-factories/depth-input.factory'; +import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.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'; +import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; + +@Injectable() +export class CommonSelectedFieldsHandler { + computeFromDepth = ({ + objectsPermissions, + objectMetadataMaps, + objectMetadataMapItem, + depth, + }: { + objectsPermissions: ObjectsPermissions; + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; + depth: Depth | undefined; + }): CommonSelectedFieldsResult => { + const restrictedFields = + objectsPermissions[objectMetadataMapItem.id].restrictedFields; + + const { relations, relationsSelectFields } = + this.getRelationsAndRelationsSelectFields({ + objectMetadataMaps, + objectMetadataMapItem, + objectsPermissions, + depth, + }); + + const selectableFields = getAllSelectableFields({ + restrictedFields, + objectMetadata: { + objectMetadataMapItem, + }, + }); + + return { + select: { + ...selectableFields, + ...relationsSelectFields, + }, + relations, + aggregate: {}, + }; + }; + + private getRelationsAndRelationsSelectFields({ + objectMetadataMaps, + objectMetadataMapItem, + objectsPermissions, + depth, + }: { + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; + objectsPermissions: ObjectsPermissions; + depth: Depth | undefined; + }) { + if (!isDefined(depth) || depth === 0) { + return { + relations: {}, + relationsSelectFields: {}, + }; + } + + let relations: { [key: string]: boolean | { [key: string]: boolean } } = {}; + + let relationsSelectFields: { + [key: string]: + | boolean + | { [key: string]: boolean | { [key: string]: boolean } }; + } = {}; + + for (const field of Object.values(objectMetadataMapItem.fieldsById)) { + if (!isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) + continue; + + const relationTargetObjectMetadata = + objectMetadataMaps.byId[field.relationTargetObjectMetadataId]; + + if (!isDefined(relationTargetObjectMetadata)) { + throw new BadRequestException( + `Object metadata relation target not found for relation creation payload`, + ); + } + const relationFieldSelectFields = getAllSelectableFields({ + restrictedFields: + objectsPermissions[relationTargetObjectMetadata.id].restrictedFields, + objectMetadata: { + objectMetadataMapItem: relationTargetObjectMetadata, + }, + }); + + if (Object.keys(relationFieldSelectFields).length === 0) continue; + + if ( + depth === MAX_DEPTH && + isDefined(field.relationTargetObjectMetadataId) + ) { + const { + relations: depth2Relations, + relationsSelectFields: depth2RelationsSelectFields, + } = this.getRelationsAndRelationsSelectFields({ + objectMetadataMaps, + objectMetadataMapItem: relationTargetObjectMetadata, + objectsPermissions, + depth: 1, + }) as { + relations: { [key: string]: boolean }; + relationsSelectFields: { + [key: string]: boolean; + }; + }; + + relations[field.name] = depth2Relations as { + [key: string]: boolean; + }; + + relationsSelectFields[field.name] = { + ...relationFieldSelectFields, + ...depth2RelationsSelectFields, + }; + } else { + relations[field.name] = true; + relationsSelectFields[field.name] = relationFieldSelectFields; + } + } + + return { relations, relationsSelectFields }; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts new file mode 100644 index 0000000000000..660056a2e1a6f --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts @@ -0,0 +1,214 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { type IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface'; +import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface'; + +import { CommonSelectedFieldsHandler } from 'src/engine/api/common/common-args-handlers/common-query-selected-fields/common-selected-fields.handler'; +import { CommonQueryNames } from 'src/engine/api/common/types/common-query-args.type'; +import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; +import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/query-result-getters.factory'; +import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory'; +import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; +import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +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 { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.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'; + +@Injectable() +export abstract class CommonBaseQueryRunnerService< + Response extends + | ObjectRecord + | ObjectRecord[] + | IConnection> + | IConnection>[], +> { + @Inject() + protected readonly workspaceQueryHookService: WorkspaceQueryHookService; + @Inject() + protected readonly queryRunnerArgsFactory: QueryRunnerArgsFactory; + @Inject() + protected readonly queryResultGettersFactory: QueryResultGettersFactory; + @Inject() + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager; + @Inject() + protected readonly processNestedRelationsHelper: ProcessNestedRelationsHelper; + @Inject() + protected readonly permissionsService: PermissionsService; + @Inject() + protected readonly userRoleService: UserRoleService; + @Inject() + protected readonly apiKeyRoleService: ApiKeyRoleService; + @Inject() + protected readonly selectedFieldsHandler: CommonSelectedFieldsHandler; + @Inject() + protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService; + + public async prepareQueryRunnerContext({ + authContext, + objectMetadataItemWithFieldMaps, + }: { + authContext: WorkspaceAuthContext; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + }) { + if (objectMetadataItemWithFieldMaps.isSystem === true) { + await this.validateSettingsPermissionsOnObjectOrThrow( + authContext, + objectMetadataItemWithFieldMaps, + ); + } + + const workspace = authContext.workspace; + + const workspaceDataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace({ + workspaceId: workspace.id, + }); + + const { roleId } = await this.getRoleIdAndObjectsPermissions( + authContext, + workspace.id, + ); + + const repository = workspaceDataSource.getRepository( + objectMetadataItemWithFieldMaps.nameSingular, + false, + roleId, + authContext, + ); + + return { + workspaceDataSource, + repository, + isExecutedByApiKey: isDefined(authContext.apiKey), + roleId, + shouldBypassPermissionChecks: false, + }; + } + + public async enrichResultsWithGettersAndHooks({ + results, + operationName, + authContext, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + }: { + results: Response; + operationName: CommonQueryNames; + authContext: WorkspaceAuthContext; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + objectMetadataMaps: ObjectMetadataMaps; + }) { + const resultWithGetters = await this.queryResultGettersFactory.create( + results, + objectMetadataItemWithFieldMaps, + authContext.workspace.id, + objectMetadataMaps, + ); + + await this.workspaceQueryHookService.executePostQueryHooks( + authContext, + objectMetadataItemWithFieldMaps.nameSingular, + operationName, + resultWithGetters, + ); + + return resultWithGetters; + } + + private async validateSettingsPermissionsOnObjectOrThrow( + authContext: WorkspaceAuthContext, + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, + ) { + const workspace = authContext.workspace; + + if ( + Object.keys(OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS).includes( + objectMetadataItemWithFieldMaps.nameSingular, + ) + ) { + const permissionRequired: PermissionFlagType = + OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS[ + objectMetadataItemWithFieldMaps.nameSingular as keyof typeof OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS + ]; + + const userHasPermission = + await this.permissionsService.userHasWorkspaceSettingPermission({ + userWorkspaceId: authContext.userWorkspaceId, + setting: permissionRequired, + workspaceId: workspace.id, + apiKeyId: authContext.apiKey?.id, + }); + + if (!userHasPermission) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + } + } + + private async getRoleIdAndObjectsPermissions( + authContext: AuthContext, + workspaceId: string, + ) { + let roleId: string; + + if ( + !isDefined(authContext.apiKey) && + !isDefined(authContext.userWorkspaceId) + ) { + throw new PermissionsException( + PermissionsExceptionMessage.NO_AUTHENTICATION_CONTEXT, + PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT, + ); + } + + if (isDefined(authContext.apiKey)) { + roleId = await this.apiKeyRoleService.getRoleIdForApiKey( + authContext.apiKey.id, + workspaceId, + ); + } else { + const userWorkspaceRoleId = + await this.userRoleService.getRoleIdForUserWorkspace({ + userWorkspaceId: authContext.userWorkspaceId, + workspaceId, + }); + + if (!isDefined(userWorkspaceRoleId)) { + throw new PermissionsException( + PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE, + PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE, + ); + } + + roleId = userWorkspaceRoleId; + } + + const objectMetadataPermissions = + await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles( + { + workspaceId: workspaceId, + roleIds: [roleId], + }, + ); + + return { roleId, objectsPermissions: objectMetadataPermissions[roleId] }; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts new file mode 100644 index 0000000000000..9b55554110eed --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts @@ -0,0 +1,185 @@ +import { Injectable } from '@nestjs/common'; + +import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; +import { FindOptionsRelations, ObjectLiteral } from 'typeorm'; + +import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; +import { + ObjectRecord, + ObjectRecordFilter, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; +import { + CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; +import { + CommonQueryNames, + FindOneQueryArgs, +} from 'src/engine/api/common/types/common-query-args.type'; +import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +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 CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerService { + async run({ + args, + authContext: toValidateAuthContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }: { + args: FindOneQueryArgs; + authContext: AuthContext; + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + }): Promise { + const authContext = toValidateAuthContext; + + if (!isWorkspaceAuthContext(authContext)) { + throw new CommonQueryRunnerException( + 'Invalid auth context', + CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT, + ); + } + + const { + workspaceDataSource, + repository, + roleId, + shouldBypassPermissionChecks, + } = await this.prepareQueryRunnerContext({ + authContext, + objectMetadataItemWithFieldMaps, + }); + + const processedArgs = await this.processQueryArgs({ + authContext, + objectMetadataItemWithFieldMaps, + args, + }); + + if ( + !processedArgs.filter || + Object.keys(processedArgs.filter).length === 0 + ) { + throw new CommonQueryRunnerException( + 'Missing filter argument', + CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + + const queryBuilder = repository.createQueryBuilder( + objectMetadataItemWithFieldMaps.nameSingular, + ); + + //TODO : Refacto-common - QueryParser should be common branded service + const commonQueryParser = new GraphqlQueryParser( + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + ); + + commonQueryParser.applyFilterToBuilder( + queryBuilder, + objectMetadataItemWithFieldMaps.nameSingular, + processedArgs.filter ?? ({} as ObjectRecordFilter), + ); + + commonQueryParser.applyDeletedAtToBuilder( + queryBuilder, + processedArgs.filter ?? ({} as ObjectRecordFilter), + ); + + const columnsToSelect = buildColumnsToSelect({ + select: args.selectedFieldsResult.select, + relations: args.selectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + }); + + const objectRecord = await queryBuilder + .setFindOptions({ + select: columnsToSelect, + }) + .getOne(); + + if (!objectRecord) { + throw new CommonQueryRunnerException( + 'Record not found', + CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND, + ); + } + + const objectRecords = [objectRecord] as ObjectRecord[]; + + if (isDefined(args.selectedFieldsResult.relations)) { + await this.processNestedRelationsHelper.processNestedRelations({ + objectMetadataMaps, + parentObjectMetadataItem: objectMetadataItemWithFieldMaps, + parentObjectRecords: objectRecords, + //TODO : Refacto-common - To fix when switching processNestedRelationsHelper to Common + relations: args.selectedFieldsResult.relations as Record< + string, + FindOptionsRelations + >, + limit: QUERY_MAX_RECORDS, + authContext, + workspaceDataSource, + roleId, + shouldBypassPermissionChecks, + selectedFields: args.selectedFieldsResult.select, + }); + } + + const typeORMObjectRecordsParser = + new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps); + + const results = typeORMObjectRecordsParser.processRecord({ + objectRecord: objectRecords[0], + objectName: objectMetadataItemWithFieldMaps.nameSingular, + take: 1, + totalCount: 1, + }) as ObjectRecord; + + return this.enrichResultsWithGettersAndHooks({ + results, + authContext, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + operationName: CommonQueryNames.findOne, + }); + } + + async processQueryArgs({ + authContext, + objectMetadataItemWithFieldMaps, + args, + }: { + authContext: WorkspaceAuthContext; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + args: FindOneQueryArgs; + }): Promise { + const hookedArgs = + (await this.workspaceQueryHookService.executePreQueryHooks( + authContext, + objectMetadataItemWithFieldMaps.nameSingular, + CommonQueryNames.findOne, + args, + //TODO : Refacto-common - To fix when updating workspaceQueryHookService, removing gql typing dependency + )) as FindOneQueryArgs; + + return { + ...hookedArgs, + filter: this.queryRunnerArgsFactory.overrideFilterByFieldMetadata( + hookedArgs.filter, + objectMetadataItemWithFieldMaps, + ), + }; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts new file mode 100644 index 0000000000000..f946c9616ef55 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts @@ -0,0 +1,3 @@ +import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service'; + +export const CommonQueryRunners = [CommonFindOneQueryRunnerService]; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts new file mode 100644 index 0000000000000..af4eaaefde06f --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts @@ -0,0 +1,9 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class CommonQueryRunnerException extends CustomException {} + +export enum CommonQueryRunnerExceptionCode { + RECORD_NOT_FOUND = 'RECORD_NOT_FOUND', + INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT', + INVALID_AUTH_CONTEXT = 'INVALID_AUTH_CONTEXT', +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts new file mode 100644 index 0000000000000..f8b2adc008e1b --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts @@ -0,0 +1,27 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + CommonQueryRunnerExceptionCode, + type CommonQueryRunnerException, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; +import { + AuthenticationError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export const commonQueryRunnerToGraphqlApiExceptionHandler = ( + error: CommonQueryRunnerException, +) => { + switch (error.code) { + case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND: + throw new NotFoundError(error); + case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT: + throw new UserInputError(error); + case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: + throw new AuthenticationError(error); + default: { + return assertUnreachable(error.code); + } + } +}; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts new file mode 100644 index 0000000000000..413f82d446bda --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts @@ -0,0 +1,28 @@ +import { + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; + +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + CommonQueryRunnerExceptionCode, + type CommonQueryRunnerException, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; + +export const commonQueryRunnerToRestApiExceptionHandler = ( + error: CommonQueryRunnerException, +): never => { + switch (error.code) { + case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT: + throw new BadRequestException(error.message); + case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND: + throw new NotFoundException('Record not found'); + case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: + throw new UnauthorizedException(error.message); + default: { + return assertUnreachable(error.code); + } + } +}; diff --git a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts new file mode 100644 index 0000000000000..8acc5b706ee51 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { CommonArgsHandlers } from 'src/engine/api/common/common-args-handlers/common-query-selected-fields/common-arg-handlers'; +import { CommonQueryRunners } from 'src/engine/api/common/common-query-runners/common-query-runners'; +import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; +import { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; +import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module'; +import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module'; +import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module'; + +@Module({ + imports: [ + WorkspaceQueryHookModule, + WorkspaceQueryRunnerModule, + PermissionsModule, + TypeOrmModule.forFeature([RoleTargetsEntity]), + UserRoleModule, + ApiKeyModule, + WorkspacePermissionsCacheModule, + ], + providers: [ + ProcessNestedRelationsHelper, + ProcessNestedRelationsV2Helper, + ...CommonArgsHandlers, + ProcessAggregateHelper, + ...CommonQueryRunners, + ], + exports: [...CommonQueryRunners], +}) +export class CoreCommonApiModule {} diff --git a/packages/twenty-server/src/engine/api/common/interfaces/workspace-auth-context.interface.ts b/packages/twenty-server/src/engine/api/common/interfaces/workspace-auth-context.interface.ts new file mode 100644 index 0000000000000..7556dcefd89f0 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/interfaces/workspace-auth-context.interface.ts @@ -0,0 +1,21 @@ +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; + +interface AuthContextWithDefinedWorkspaceProperties { + user: AuthContext['user']; + workspace: NonNullable; + workspaceMetadataVersion?: string; + workspaceMemberId: AuthContext['workspaceMemberId']; + userWorkspaceId: AuthContext['userWorkspaceId']; + apiKey: AuthContext['apiKey']; +} + +interface ApiKeyAuthContext extends Request { + apiKey: NonNullable; +} + +interface UserWorkspaceAuthContext extends Request { + userWorkspaceId: NonNullable; +} + +export type WorkspaceAuthContext = AuthContextWithDefinedWorkspaceProperties & + (ApiKeyAuthContext | UserWorkspaceAuthContext); diff --git a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts new file mode 100644 index 0000000000000..052dab93c834c --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts @@ -0,0 +1,14 @@ +import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; + +export enum CommonQueryNames { + findOne = 'findOne', +} + +export interface FindOneQueryArgs { + selectedFieldsResult: CommonSelectedFieldsResult; + filter?: ObjectRecordFilter; +} + +export type CommonQueryArgs = FindOneQueryArgs; diff --git a/packages/twenty-server/src/engine/api/common/types/common-selected-fields-result.type.ts b/packages/twenty-server/src/engine/api/common/types/common-selected-fields-result.type.ts new file mode 100644 index 0000000000000..baac2e6eab0fe --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/types/common-selected-fields-result.type.ts @@ -0,0 +1,9 @@ +interface SelectedFields { + [key: string]: boolean | SelectedFields; +} + +export type CommonSelectedFieldsResult = { + select: SelectedFields; + relations: SelectedFields; + aggregate: SelectedFields; +}; diff --git a/packages/twenty-server/src/engine/api/common/utils/is-workspace-auth-context.util.ts b/packages/twenty-server/src/engine/api/common/utils/is-workspace-auth-context.util.ts new file mode 100644 index 0000000000000..c4c5e1d455829 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/utils/is-workspace-auth-context.util.ts @@ -0,0 +1,14 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; + +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; + +export const isWorkspaceAuthContext = ( + context: AuthContext, +): context is WorkspaceAuthContext => { + return ( + isDefined(context.workspace) && + (isDefined(context.userWorkspaceId) || isDefined(context.apiKey)) + ); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts index a29dca3618082..a6899aba736d4 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts @@ -1,3 +1,4 @@ +//TODO : Refacto-common - To delete import { Injectable } from '@nestjs/common'; import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts index e0a9a167e3fb8..6604a8de65ea6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts @@ -1,3 +1,4 @@ +//TODO : Refacto-common - Should be moved to common api layer export interface ObjectRecord { id: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts index 414e4e5515c95..7554f58ae4ccd 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts @@ -7,7 +7,7 @@ import { type ObjectRecord, type ObjectRecordFilter, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; -import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { type CreateManyResolverArgs, type CreateOneResolverArgs, @@ -23,9 +23,9 @@ import { import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; import { type FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; @Injectable() export class QueryRunnerArgsFactory { @@ -214,7 +214,7 @@ export class QueryRunnerArgsFactory { return allOverriddenRecords; } - private overrideFilterByFieldMetadata( + public overrideFilterByFieldMetadata( filter: ObjectRecordFilter | undefined, objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, ) { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts index a3e664ced5d0b..6ea833fc9a849 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts @@ -6,7 +6,7 @@ import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/objec export interface WorkspaceQueryRunnerOptions { authContext: AuthContext; - info: GraphQLResolveInfo; - objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataMaps: ObjectMetadataMaps; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + info: GraphQLResolveInfo; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts index 1e07e8d394eee..f3ddbfe4a910e 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts @@ -1,5 +1,7 @@ import { type QueryFailedError } from 'typeorm'; +import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; +import { commonQueryRunnerToGraphqlApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util'; import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util'; import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util'; @@ -18,6 +20,7 @@ import { twentyORMGraphqlApiExceptionHandler } from 'src/engine/twenty-orm/utils interface QueryFailedErrorWithCode extends QueryFailedError { code: string; } +//TODO : Refacto-common - Should be handle first in common api layer export const workspaceQueryRunnerGraphqlApiExceptionHandler = ( error: QueryFailedErrorWithCode, @@ -33,6 +36,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = ( return graphqlQueryRunnerExceptionHandler(error); case error instanceof TwentyORMException: return twentyORMGraphqlApiExceptionHandler(error); + case error instanceof CommonQueryRunnerException: + return commonQueryRunnerToGraphqlApiExceptionHandler(error); case error instanceof AuthException: return authGraphqlApiExceptionHandler(error); case error instanceof ApiKeyException: diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service.ts index aec9dcd6f920a..31810a49ec5e0 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service.ts @@ -5,6 +5,7 @@ import merge from 'lodash.merge'; import { type QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-field-value'; import { type WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { CommonQueryNames } from 'src/engine/api/common/types/common-query-args.type'; import { type WorkspaceQueryHookKey } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { WorkspaceQueryHookStorage } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/storage/workspace-query-hook.storage'; import { type WorkspacePreQueryHookPayload } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type'; @@ -18,8 +19,9 @@ export class WorkspaceQueryHookService { private readonly workspaceQueryHookExplorer: WorkspaceQueryHookExplorer, ) {} + //TODO : Refacto-common - Should be Common public async executePreQueryHooks< - T extends WorkspaceResolverBuilderMethodNames, + T extends WorkspaceResolverBuilderMethodNames | CommonQueryNames, >( authContext: AuthContext, // TODO: We should allow wildcard for object name diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module.ts index c503bce302c8a..ede72c6faf624 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module.ts @@ -13,8 +13,8 @@ import { FileModule } from 'src/engine/core-modules/file/file.module'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module'; -import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { EntityEventsToDbListener } from './listeners/entity-events-to-db.listener'; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts index 9796e88468a0a..72b45fde1fad0 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts @@ -1,15 +1,21 @@ import { Injectable } from '@nestjs/common'; -import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; +import graphqlFields from 'graphql-fields'; + import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { type FindOneResolverArgs, type Resolver, } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; -import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service'; +import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class FindOneResolverFactory @@ -18,6 +24,8 @@ export class FindOneResolverFactory public static methodName = RESOLVER_METHOD_NAMES.FIND_ONE; constructor( + private readonly commonFindOneQueryRunnerService: CommonFindOneQueryRunnerService, + private readonly featureFlagService: FeatureFlagService, private readonly graphqlQueryRunnerService: GraphqlQueryFindOneResolverService, ) {} @@ -27,17 +35,45 @@ export class FindOneResolverFactory const internalContext = context; return async (_source, args, _context, info) => { - const options: WorkspaceQueryRunnerOptions = { - authContext: internalContext.authContext, - info, - objectMetadataMaps: internalContext.objectMetadataMaps, - objectMetadataItemWithFieldMaps: - internalContext.objectMetadataItemWithFieldMaps, - }; + const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_COMMON_API_ENABLED, + internalContext.authContext.workspace?.id as string, + ); + + if (isCommonApiEnabled) { + try { + const graphqlQueryParser = new GraphqlQueryParser( + internalContext.objectMetadataItemWithFieldMaps, + internalContext.objectMetadataMaps, + ); + + const selectedFieldsResult = graphqlQueryParser.parseSelectedFields( + internalContext.objectMetadataItemWithFieldMaps, + graphqlFields(info), + internalContext.objectMetadataMaps, + ); + + return await this.commonFindOneQueryRunnerService.run({ + args: { ...args, selectedFieldsResult }, + authContext: internalContext.authContext, + objectMetadataMaps: internalContext.objectMetadataMaps, + objectMetadataItemWithFieldMaps: + internalContext.objectMetadataItemWithFieldMaps, + }); + } catch (error) { + workspaceQueryRunnerGraphqlApiExceptionHandler(error); + } + } return await this.graphqlQueryRunnerService.execute( args, - options, + { + authContext: internalContext.authContext, + info, + objectMetadataMaps: internalContext.objectMetadataMaps, + objectMetadataItemWithFieldMaps: + internalContext.objectMetadataItemWithFieldMaps, + }, FindOneResolverFactory.methodName, ); }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts index 89b8b36899dda..cd2915d8d773b 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module'; import { GraphqlQueryRunnerModule } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module'; import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -9,7 +10,7 @@ import { WorkspaceResolverFactory } from './workspace-resolver.factory'; import { workspaceResolverBuilderFactories } from './factories/factories'; @Module({ - imports: [GraphqlQueryRunnerModule, FeatureFlagModule], + imports: [GraphqlQueryRunnerModule, FeatureFlagModule, CoreCommonApiModule], providers: [ ...workspaceResolverBuilderFactories, WorkspaceResolverFactory, 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 d40b76ad8cdab..9a8d6d02fc7db 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 @@ -12,10 +12,11 @@ import { UseGuards, } from '@nestjs/common'; -import { Request, Response } from 'express'; +import { Response } from 'express'; import { RestApiCoreService } from 'src/engine/api/rest/core/services/rest-api-core.service'; import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @@ -27,7 +28,10 @@ export class RestApiCoreController { constructor(private readonly restApiCoreService: RestApiCoreService) {} @Post('batch/*') - async handleApiPostBatch(@Req() request: Request, @Res() res: Response) { + async handleApiPostBatch( + @Req() request: AuthenticatedRequest, + @Res() res: Response, + ) { this.logger.log( `[REST API] Processing BATCH request to ${request.path} on workspace ${request.workspaceId}`, ); @@ -37,7 +41,10 @@ export class RestApiCoreController { } @Post('*/duplicates') - async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) { + async handleApiFindDuplicates( + @Req() request: AuthenticatedRequest, + @Res() res: Response, + ) { this.logger.log( `[REST API] Processing DUPLICATES request to ${request.path} on workspace ${request.workspaceId}`, ); @@ -47,7 +54,10 @@ export class RestApiCoreController { } @Post('*') - async handleApiPost(@Req() request: Request, @Res() res: Response) { + async handleApiPost( + @Req() request: AuthenticatedRequest, + @Res() res: Response, + ) { this.logger.log( `[REST API] Processing POST request to ${request.path} on workspace ${request.workspaceId}`, ); @@ -57,7 +67,10 @@ export class RestApiCoreController { } @Get('*') - async handleApiGet(@Req() request: Request, @Res() res: Response) { + async handleApiGet( + @Req() request: AuthenticatedRequest, + @Res() res: Response, + ) { this.logger.log( `[REST API] Processing GET request to ${request.path} on workspace ${request.workspaceId}`, ); @@ -67,7 +80,10 @@ export class RestApiCoreController { } @Delete('*') - async handleApiDelete(@Req() request: Request, @Res() res: Response) { + async handleApiDelete( + @Req() request: AuthenticatedRequest, + @Res() res: Response, + ) { this.logger.log( `[REST API] Processing DELETE request to ${request.path} on workspace ${request.workspaceId}`, ); @@ -77,7 +93,10 @@ export class RestApiCoreController { } @Patch('*') - async handleApiPatch(@Req() request: Request, @Res() res: Response) { + async handleApiPatch( + @Req() request: AuthenticatedRequest, + @Res() res: Response, + ) { this.logger.log( `[REST API] Processing PATCH request to ${request.path} on workspace ${request.workspaceId}`, ); @@ -90,7 +109,10 @@ 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('*') - async handleApiPut(@Req() request: Request, @Res() res: Response) { + async handleApiPut( + @Req() request: AuthenticatedRequest, + @Res() res: Response, + ) { this.logger.log( `[REST API] Processing PUT request to ${request.path} on workspace ${request.workspaceId}`, ); diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts index c5f42237bf256..9521b02444247 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts @@ -4,17 +4,17 @@ import { InternalServerErrorException, } from '@nestjs/common'; -import { type Request } from 'express'; import isEmpty from 'lodash.isempty'; import { isDefined } from 'twenty-shared/utils'; import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; @Injectable() export class RestApiCreateManyHandler extends RestApiBaseHandler { - async handle(request: Request) { + async handle(request: AuthenticatedRequest) { const { objectMetadata, repository, restrictedFields } = await this.getRepositoryAndMetadataOrFail(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 index 47a1e6d90e4f5..220238c4c1b92 100644 --- 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 @@ -4,17 +4,17 @@ import { InternalServerErrorException, } from '@nestjs/common'; -import { type Request } from 'express'; import isEmpty from 'lodash.isempty'; import { isDefined } from 'twenty-shared/utils'; import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; @Injectable() export class RestApiCreateOneHandler extends RestApiBaseHandler { - async handle(request: Request) { + async handle(request: AuthenticatedRequest) { const { objectMetadata, repository, restrictedFields } = await this.getRepositoryAndMetadataOrFail(request); 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 index 028dc44be6672..ccb48eb9c353c 100644 --- 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 @@ -1,15 +1,14 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { type 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'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; @Injectable() export class RestApiDeleteOneHandler extends RestApiBaseHandler { - async handle(request: Request) { + async handle(request: AuthenticatedRequest) { const { id: recordId } = parseCorePath(request); if (!recordId) { diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler.ts index 7863d36b8dfef..e91e7f9fc0c93 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler.ts @@ -10,11 +10,12 @@ import { RestApiBaseHandler, } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { buildDuplicateConditions } from 'src/engine/api/utils/build-duplicate-conditions.utils'; @Injectable() export class RestApiFindDuplicatesHandler extends RestApiBaseHandler { - async handle(request: Request) { + async handle(request: AuthenticatedRequest) { this.validate(request); const { diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-many.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-many.handler.ts index 13fb14a3ccc28..607eb2f6d3695 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-many.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-many.handler.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { type Request } from 'express'; - import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; + @Injectable() export class RestApiFindManyHandler extends RestApiBaseHandler { - async handle(request: Request) { + async handle(request: AuthenticatedRequest) { const { repository, objectMetadata, diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts index 71c04b89a9bd5..fc12e36c67057 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts @@ -1,15 +1,24 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { type Request } from 'express'; import { isDefined } from 'twenty-shared/utils'; +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; +import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; +import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util'; @Injectable() export class RestApiFindOneHandler extends RestApiBaseHandler { - async handle(request: Request) { + constructor( + private readonly commonFindOneQueryRunnerService: CommonFindOneQueryRunnerService, + ) { + super(); + } + + async handle(request: AuthenticatedRequest) { const { id: recordId } = parseCorePath(request); if (!isDefined(recordId)) { @@ -46,4 +55,69 @@ export class RestApiFindOneHandler extends RestApiBaseHandler { data: record, }); } + + async commonHandle(request: AuthenticatedRequest) { + try { + const { filter, depth } = await this.parseRequestArgs(request); + const { + authContext, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + } = await this.buildCommonOptions(request); + + const selectedFieldsResult = await this.computeSelectedFields({ + depth, + objectMetadataMapItem: objectMetadataItemWithFieldMaps, + objectMetadataMaps, + authContext, + }); + + const record = await this.commonFindOneQueryRunnerService.run({ + args: { filter, selectedFieldsResult }, + authContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }); + + return this.formatRestResponse( + record, + objectMetadataItemWithFieldMaps.nameSingular, + ); + } catch (error) { + return workspaceQueryRunnerRestApiExceptionHandler(error); + } + } + + private formatRestResponse(record: ObjectRecord, objectNameSingular: string) { + return { data: { [objectNameSingular]: record } }; + } + + private async parseRequestArgs(request: AuthenticatedRequest) { + const { id: recordId } = parseCorePath(request); + const filter = { id: { eq: recordId } }; + const depth = this.depthInputFactory.create(request); + + return { + filter, + depth, + }; + } + + private async buildCommonOptions(request: AuthenticatedRequest) { + const { object: parsedObject } = parseCorePath(request); + + const { objectMetadataMaps, objectMetadataMapItem } = + await this.coreQueryBuilderFactory.getObjectMetadata( + request, + parsedObject, + ); + + const authContext = this.getAuthContextFromRequest(request); + + return { + authContext: authContext, + objectMetadataItemWithFieldMaps: objectMetadataMapItem, + objectMetadataMaps: objectMetadataMaps, + }; + } } 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 index 43dcd495e199a..01554da99028d 100644 --- 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 @@ -4,18 +4,18 @@ import { InternalServerErrorException, } from '@nestjs/common'; -import { type Request } from 'express'; import isEmpty from 'lodash.isempty'; 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'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; @Injectable() export class RestApiUpdateOneHandler extends RestApiBaseHandler { - async handle(request: Request) { + async handle(request: AuthenticatedRequest) { const { id: recordId } = parseCorePath(request); if (!recordId) { 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 index dd17c66ecfd53..9d5e063f3df91 100644 --- 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 @@ -10,6 +10,7 @@ import { import { capitalize, isDefined } from 'twenty-shared/utils'; import { In, type ObjectLiteral } from 'typeorm'; +import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; import { type ObjectRecord, type ObjectRecordFilter, @@ -20,17 +21,18 @@ import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/ import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory'; import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; +import { RestToCommonSelectedFieldsHandler } from 'src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler'; import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type'; import { - type Depth, DepthInputFactory, MAX_DEPTH, + type Depth, } from 'src/engine/api/rest/input-factories/depth-input.factory'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service'; -import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { @@ -40,13 +42,16 @@ import { } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; 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 { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; export interface PageInfo { hasNextPage?: boolean; @@ -94,13 +99,21 @@ export abstract class RestApiBaseHandler { @Inject() protected readonly createdByFromAuthContextService: CreatedByFromAuthContextService; @Inject() + protected readonly workspaceCacheStorageService: WorkspaceCacheStorageService; + @Inject() + protected readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService; + @Inject() protected readonly apiKeyRoleService: ApiKeyRoleService; + @Inject() + protected readonly restToCommonSelectedFieldsHandler: RestToCommonSelectedFieldsHandler; + @Inject() + protected readonly userRoleService: UserRoleService; protected abstract handle( - request: Request, + request: AuthenticatedRequest, ): Promise; - public async getRepositoryAndMetadataOrFail(request: Request) { + public async getRepositoryAndMetadataOrFail(request: AuthenticatedRequest) { const { workspace, apiKey, userWorkspaceId } = request; const { object: parsedObject } = parseCorePath(request); @@ -109,14 +122,14 @@ export abstract class RestApiBaseHandler { parsedObject, ); - if (!objectMetadata) { - throw new BadRequestException('Object metadata not found'); - } - if (!workspace?.id) { throw new BadRequestException('Workspace not found'); } + if (!objectMetadata) { + throw new BadRequestException('Object metadata not found'); + } + const workspaceDataSource = await this.twentyORMManager.getDatasource(); const objectMetadataNameSingular = @@ -134,18 +147,22 @@ export abstract class RestApiBaseHandler { ); } - let roleId: string | undefined = undefined; - let shouldBypassPermissionChecks = false; + let roleId: string; if (isDefined(apiKey)) { roleId = await this.apiKeyRoleService.getRoleIdForApiKey( apiKey.id, workspace.id, ); - } - if (isDefined(userWorkspaceId)) { - roleId = + if (!isDefined(roleId)) { + throw new PermissionsException( + PermissionsExceptionMessage.API_KEY_ROLE_NOT_FOUND, + PermissionsExceptionCode.API_KEY_ROLE_NOT_FOUND, + ); + } + } else { + const userWorkspaceRoleId = await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId( { workspaceId: workspace.id, @@ -153,60 +170,53 @@ export abstract class RestApiBaseHandler { }, ); - if (!roleId) { + if (!isDefined(userWorkspaceRoleId)) { throw new PermissionsException( PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE, PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE, ); } - } - if (!isDefined(apiKey) && !isDefined(userWorkspaceId)) { - throw new PermissionsException( - PermissionsExceptionMessage.NO_AUTHENTICATION_CONTEXT, - PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT, - ); + roleId = userWorkspaceRoleId; } const repository = workspaceDataSource.getRepository( objectMetadataNameSingular, - shouldBypassPermissionChecks, + false, roleId, ); - let restrictedFields: RestrictedFieldsPermissions = {}; - - if (roleId) { - const objectMetadataPermissions = - await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles( - { - workspaceId: workspace.id, - roleIds: roleId ? [roleId] : undefined, - }, - ); - - if ( - !isDefined( - objectMetadataPermissions?.[roleId]?.[ - objectMetadata.objectMetadataMapItem.id - ]?.restrictedFields, - ) - ) { - throw new InternalServerError('Fields permissions not found for role'); - } + const objectMetadataPermissions = + await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles( + { + workspaceId: workspace.id, + roleIds: roleId ? [roleId] : undefined, + }, + ); - restrictedFields = - objectMetadataPermissions[roleId][ + if ( + !isDefined( + objectMetadataPermissions?.[roleId]?.[ objectMetadata.objectMetadataMapItem.id - ].restrictedFields; + ]?.restrictedFields, + ) + ) { + throw new InternalServerError('Fields permissions not found for role'); } + const restrictedFields = + objectMetadataPermissions[roleId][objectMetadata.objectMetadataMapItem.id] + .restrictedFields; + return { objectMetadata, repository, workspaceDataSource, objectMetadataItemWithFieldsMaps, restrictedFields, + isExecutedByApiKey: isDefined(apiKey), + authContext: this.getAuthContextFromRequest(request), + objectsPermissions: objectMetadataPermissions[roleId], }; } @@ -325,14 +335,10 @@ export abstract class RestApiBaseHandler { return orderedRecords; } - public getAuthContextFromRequest(request: Request): AuthContext { - return { - user: request.user, - workspace: request.workspace, - apiKey: request.apiKey, - workspaceMemberId: request.workspaceMemberId, - userWorkspaceId: request.userWorkspaceId, - }; + public getAuthContextFromRequest( + request: AuthenticatedRequest, + ): WorkspaceAuthContext { + return request; } public formatResult({ @@ -546,4 +552,62 @@ export abstract class RestApiBaseHandler { throw new BadRequestException(`Invalid cursor: ${cursor}`); } }; + + private getObjectsPermissions = async (authContext: WorkspaceAuthContext) => { + let roleId: string; + + if (isDefined(authContext.apiKey)) { + roleId = await this.apiKeyRoleService.getRoleIdForApiKey( + authContext.apiKey.id, + authContext.workspace.id, + ); + } else { + const userWorkspaceRoleId = + await this.userRoleService.getRoleIdForUserWorkspace({ + userWorkspaceId: authContext.userWorkspaceId, + workspaceId: authContext.workspace.id, + }); + + if (!isDefined(userWorkspaceRoleId)) { + throw new PermissionsException( + PermissionsExceptionMessage.NO_ROLE_FOUND_FOR_USER_WORKSPACE, + PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE, + ); + } + + roleId = userWorkspaceRoleId; + } + + const objectMetadataPermissions = + await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles( + { + workspaceId: authContext.workspace.id, + roleIds: [roleId], + }, + ); + + return { objectsPermissions: objectMetadataPermissions[roleId] }; + }; + + async computeSelectedFields({ + authContext, + depth, + objectMetadataMapItem, + objectMetadataMaps, + }: { + authContext: WorkspaceAuthContext; + depth: Depth; + objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; + objectMetadataMaps: ObjectMetadataMaps; + }) { + const { objectsPermissions } = + await this.getObjectsPermissions(authContext); + + return this.restToCommonSelectedFieldsHandler.computeFromDepth({ + objectsPermissions, + objectMetadataMaps, + objectMetadataMapItem, + depth, + }); + } } diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.module.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.module.ts index b927f1761ca0e..72d7847bb2349 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.module.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.module.ts @@ -2,19 +2,23 @@ import { Module } from '@nestjs/common'; import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory'; import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories'; +import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; +import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @Module({ imports: [ AuthModule, + ApiKeyModule, DomainManagerModule, FeatureFlagModule, WorkspaceCacheStorageModule, WorkspaceMetadataCacheModule, + WorkspacePermissionsCacheModule, ], providers: [...coreQueryBuilderFactories, CoreQueryBuilderFactory], exports: [CoreQueryBuilderFactory], 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 16f34a21aedf3..f0405a5315230 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,8 +1,8 @@ import { CreateManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-many-query.factory'; +import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-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 { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-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 = [ 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 cf3de74f33e36..1df2ec2587579 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 @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { type Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; import { type QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type'; import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory'; @@ -29,7 +30,7 @@ export class GetVariablesFactory { objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; }, ): QueryVariables { - if (id) { + if (isDefined(id)) { return { filter: { id: { eq: id } } }; } 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 index a71a8c0a6836e..8fc8dbe0d8a1a 100644 --- 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 @@ -1,6 +1,7 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module'; import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller'; import { RestApiCreateManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-many.handler'; import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler'; @@ -11,13 +12,17 @@ import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-ap import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module'; import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories'; +import { restToCommonArgsHandlers } from 'src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers'; import { RestApiCoreService } from 'src/engine/api/rest/core/services/rest-api-core.service'; import { RestApiService } from 'src/engine/api/rest/rest-api.service'; import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.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'; @@ -38,12 +43,16 @@ const restApiCoreResolvers = [ WorkspaceCacheStorageModule, AuthModule, ApiKeyModule, + UserRoleModule, HttpModule, TwentyORMModule, RecordTransformerModule, WorkspacePermissionsCacheModule, + WorkspaceMetadataCacheModule, ActorModule, FeatureFlagModule, + CoreCommonApiModule, + DomainManagerModule, ], controllers: [RestApiCoreController], providers: [ @@ -51,6 +60,7 @@ const restApiCoreResolvers = [ RestApiCoreService, ...coreQueryBuilderFactories, ...restApiCoreResolvers, + ...restToCommonArgsHandlers, ], }) export class RestApiCoreModule {} diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers.ts b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers.ts new file mode 100644 index 0000000000000..0e97295388352 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/rest-to-common-args-handlers.ts @@ -0,0 +1,3 @@ +import { RestToCommonSelectedFieldsHandler } from 'src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler'; + +export const restToCommonArgsHandlers = [RestToCommonSelectedFieldsHandler]; diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts new file mode 100644 index 0000000000000..86925b4440727 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts @@ -0,0 +1,140 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; +import { + Depth, + MAX_DEPTH, +} from 'src/engine/api/rest/input-factories/depth-input.factory'; +import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.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'; +import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; + +@Injectable() +export class RestToCommonSelectedFieldsHandler { + computeFromDepth = ({ + objectsPermissions, + objectMetadataMaps, + objectMetadataMapItem, + depth, + }: { + objectsPermissions: ObjectsPermissions; + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; + depth: Depth | undefined; + }): CommonSelectedFieldsResult => { + const restrictedFields = + objectsPermissions[objectMetadataMapItem.id].restrictedFields; + + const { relations, relationsSelectFields } = + this.getRelationsAndRelationsSelectFields({ + objectMetadataMaps, + objectMetadataMapItem, + objectsPermissions, + depth, + }); + + const selectableFields = getAllSelectableFields({ + restrictedFields, + objectMetadata: { + objectMetadataMapItem, + }, + }); + + return { + select: { + ...selectableFields, + ...relationsSelectFields, + }, + relations, + aggregate: {}, + }; + }; + + private getRelationsAndRelationsSelectFields({ + objectMetadataMaps, + objectMetadataMapItem, + objectsPermissions, + depth, + }: { + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; + objectsPermissions: ObjectsPermissions; + depth: Depth | undefined; + }) { + if (!isDefined(depth) || depth === 0) { + return { + relations: {}, + relationsSelectFields: {}, + }; + } + + let relations: { [key: string]: boolean | { [key: string]: boolean } } = {}; + + let relationsSelectFields: { + [key: string]: + | boolean + | { [key: string]: boolean | { [key: string]: boolean } }; + } = {}; + + for (const field of Object.values(objectMetadataMapItem.fieldsById)) { + if (!isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) + continue; + + const relationTargetObjectMetadata = + objectMetadataMaps.byId[field.relationTargetObjectMetadataId]; + + if (!isDefined(relationTargetObjectMetadata)) { + throw new BadRequestException( + `Object metadata relation target not found for relation creation payload`, + ); + } + const relationFieldSelectFields = getAllSelectableFields({ + restrictedFields: + objectsPermissions[relationTargetObjectMetadata.id].restrictedFields, + objectMetadata: { + objectMetadataMapItem: relationTargetObjectMetadata, + }, + }); + + if (Object.keys(relationFieldSelectFields).length === 0) continue; + + if ( + depth === MAX_DEPTH && + isDefined(field.relationTargetObjectMetadataId) + ) { + const { + relations: depth2Relations, + relationsSelectFields: depth2RelationsSelectFields, + } = this.getRelationsAndRelationsSelectFields({ + objectMetadataMaps, + objectMetadataMapItem: relationTargetObjectMetadata, + objectsPermissions, + depth: 1, + }) as { + relations: { [key: string]: boolean }; + relationsSelectFields: { + [key: string]: boolean; + }; + }; + + relations[field.name] = depth2Relations as { + [key: string]: boolean; + }; + + relationsSelectFields[field.name] = { + ...relationFieldSelectFields, + ...depth2RelationsSelectFields, + }; + } else { + relations[field.name] = true; + relationsSelectFields[field.name] = relationFieldSelectFields; + } + } + + return { relations, relationsSelectFields }; + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts b/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts index e8ee3bfd5be5d..cbaf20b5f4c62 100644 --- a/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts +++ b/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts @@ -1,16 +1,18 @@ import { Injectable } from '@nestjs/common'; -import { type Request } from 'express'; import { isDefined } from 'twenty-shared/utils'; -import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; -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 { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler'; -import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler'; import { RestApiCreateManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-many.handler'; +import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler'; +import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-delete-one.handler'; import { RestApiFindDuplicatesHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler'; +import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler'; +import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler'; +import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; +import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class RestApiCoreService { @@ -22,32 +24,42 @@ export class RestApiCoreService { private readonly restApiFindOneHandler: RestApiFindOneHandler, private readonly restApiFindManyHandler: RestApiFindManyHandler, private readonly restApiFindDuplicatesHandler: RestApiFindDuplicatesHandler, + private readonly featureFlagService: FeatureFlagService, ) {} - async delete(request: Request) { + async delete(request: AuthenticatedRequest) { return await this.restApiDeleteOneHandler.handle(request); } - async createOne(request: Request) { + async createOne(request: AuthenticatedRequest) { return await this.restApiCreateOneHandler.handle(request); } - async createMany(request: Request) { + async createMany(request: AuthenticatedRequest) { return await this.restApiCreateManyHandler.handle(request); } - async findDuplicates(request: Request) { + async findDuplicates(request: AuthenticatedRequest) { return await this.restApiFindDuplicatesHandler.handle(request); } - async update(request: Request) { + async update(request: AuthenticatedRequest) { return await this.restApiUpdateOneHandler.handle(request); } - async get(request: Request) { + async get(request: AuthenticatedRequest) { const { id: recordId } = parseCorePath(request); if (isDefined(recordId)) { + const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_COMMON_API_ENABLED, + request.workspace.id, + ); + + if (isCommonApiEnabled) { + return await this.restApiFindOneHandler.commonHandle(request); + } + return await this.restApiFindOneHandler.handle(request); } else { return await this.restApiFindManyHandler.handle(request); diff --git a/packages/twenty-server/src/engine/api/rest/types/authenticated-request.ts b/packages/twenty-server/src/engine/api/rest/types/authenticated-request.ts new file mode 100644 index 0000000000000..c8c7c551790b5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/types/authenticated-request.ts @@ -0,0 +1,5 @@ +import { type Request } from 'express'; + +import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; + +export type AuthenticatedRequest = Request & WorkspaceAuthContext; diff --git a/packages/twenty-server/src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util.ts new file mode 100644 index 0000000000000..6278b857e9b55 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util.ts @@ -0,0 +1,19 @@ +import { type QueryFailedError } from 'typeorm'; + +import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; +import { commonQueryRunnerToRestApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util'; + +interface QueryFailedErrorWithCode extends QueryFailedError { + code: string; +} + +export const workspaceQueryRunnerRestApiExceptionHandler = ( + error: QueryFailedErrorWithCode, +): never => { + switch (true) { + case error instanceof CommonQueryRunnerException: + return commonQueryRunnerToRestApiExceptionHandler(error); + default: + throw error; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index e5e9bdd16ae61..cefe5e72bee83 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -19,4 +19,5 @@ export enum FeatureFlagKey { IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED', IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED', + IS_COMMON_API_ENABLED = 'IS_COMMON_API_ENABLED', } diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index b35d3906d73b4..35585af19aa89 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -4,6 +4,8 @@ import { Injectable, } from '@nestjs/common'; +import { isDefined } from 'twenty-shared/utils'; + import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @@ -26,6 +28,10 @@ export class JwtAuthGuard implements CanActivate { ) : undefined; + if (!isDefined(data.apiKey) && !isDefined(data.userWorkspaceId)) { + return false; + } + request.user = data.user; request.apiKey = data.apiKey; request.workspace = data.workspace; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 1b61f66a2d776..dd9f451c688d0 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -142,6 +142,7 @@ describe('WorkspaceEntityManager', () => { IS_PUBLIC_DOMAIN_ENABLED: false, IS_EMAILING_DOMAIN_ENABLED: false, IS_DYNAMIC_SEARCH_FIELDS_ENABLED: false, + IS_COMMON_API_ENABLED: false, }, eventEmitterService: { emitMutationEvent: jest.fn(), diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 0342787e76299..4607b77500684 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -56,6 +56,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: workspaceId === SEED_APPLE_WORKSPACE_ID, }, + { + key: FeatureFlagKey.IS_COMMON_API_ENABLED, + workspaceId: workspaceId, + value: false, + }, { key: FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED, workspaceId: workspaceId, 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 index 1adfef3680ec5..63309f045c5dc 100644 --- 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 @@ -62,9 +62,11 @@ describe('Core REST API Find One endpoint', () => { method: 'get', path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`, }) + //TODO : Refacto-common - This should be a 404 .expect(400) .expect((res) => { expect(res.body.messages[0]).toContain('Record not found'); + //TODO : Refacto-common - This should be a NotFoundException expect(res.body.error).toBe('BadRequestException'); }); });