diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts index 4e5d40757c..cd6f733044 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts @@ -51,7 +51,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer if (args.data.length > QUERY_MAX_RECORDS) { throw new CommonQueryRunnerException( `Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`, - CommonQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED, + CommonQueryRunnerExceptionCode.TOO_MANY_RECORDS_TO_UPDATE, { userFriendlyMessage: msg`Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`, }, 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 index 41de004a6e..58fa42d93e 100644 --- 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 @@ -13,6 +13,6 @@ export enum CommonQueryRunnerExceptionCode { UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT = 'UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT', MISSING_SYSTEM_FIELD = 'MISSING_SYSTEM_FIELD', INVALID_CURSOR = 'INVALID_CURSOR', - UPSERT_MAX_RECORDS_EXCEEDED = 'UPSERT_MAX_RECORDS_EXCEEDED', + TOO_MANY_RECORDS_TO_UPDATE = 'TOO_MANY_RECORDS_TO_UPDATE', BAD_REQUEST = 'BAD_REQUEST', } 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 index 562ad7ccd9..72fd0a0085 100644 --- 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 @@ -6,6 +6,7 @@ import { } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { AuthenticationError, + InternalServerError, NotFoundError, UserInputError, } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @@ -23,13 +24,13 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA: case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT: case CommonQueryRunnerExceptionCode.INVALID_CURSOR: - case CommonQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED: + case CommonQueryRunnerExceptionCode.TOO_MANY_RECORDS_TO_UPDATE: case CommonQueryRunnerExceptionCode.BAD_REQUEST: throw new UserInputError(error); case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new AuthenticationError(error); case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD: - throw error; + throw new InternalServerError(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 index 07a9c569f9..ac2aa6bc35 100644 --- 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 @@ -1,5 +1,6 @@ import { BadRequestException, + InternalServerErrorException, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -22,7 +23,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA: case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT: case CommonQueryRunnerExceptionCode.INVALID_CURSOR: - case CommonQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED: + case CommonQueryRunnerExceptionCode.TOO_MANY_RECORDS_TO_UPDATE: case CommonQueryRunnerExceptionCode.BAD_REQUEST: throw new BadRequestException(error.message); case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND: @@ -30,7 +31,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new UnauthorizedException(error.message); case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD: - throw error; + throw new InternalServerErrorException(error.message); default: { return assertUnreachable(error.code); } diff --git a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts index cca7501f14..395cc81a6b 100644 --- a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts +++ b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts @@ -1,6 +1,6 @@ import deepEqual from 'deep-equal'; -import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata'; +import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; @@ -15,7 +15,7 @@ const isWorkflowVersionStepsOrTrigger = ( }; const isWorkflowAutomatedTriggerSettings = ( - objectMetadataItem: ObjectMetadataItemWithFieldMaps, + objectMetadataItem: Pick, key: string, ) => { return ( diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index ffe53ff2cf..0f48536d6b 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -196,7 +196,7 @@ export class WorkspaceEntityManager extends EntityManager { options?.objectRecordsPermissions ?? {}, this.internalContext, options?.shouldBypassPermissionChecks ?? false, - undefined, + {}, this.getFeatureFlagMap(), ); } diff --git a/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts b/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts index 12d7cb48fc..17e62a23ff 100644 --- a/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts +++ b/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts @@ -19,5 +19,7 @@ export enum TwentyORMExceptionCode { ENUM_TYPE_NAME_NOT_FOUND = 'ENUM_TYPE_NAME_NOT_FOUND', QUERY_READ_TIMEOUT = 'QUERY_READ_TIMEOUT', DUPLICATE_ENTRY_DETECTED = 'DUPLICATE_ENTRY_DETECTED', + TOO_MANY_RECORDS_TO_UPDATE = 'TOO_MANY_RECORDS_TO_UPDATE', INVALID_INPUT = 'INVALID_INPUT', + ORM_EVENT_DATA_CORRUPTED = 'ORM_EVENT_DATA_CORRUPTED', } diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts index 02913ddd54..54774d07c0 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts @@ -20,10 +20,11 @@ import { TwentyORMExceptionCode, } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils'; -import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; +import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder'; import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder'; import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition'; +import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util'; import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util'; @@ -35,15 +36,15 @@ export class WorkspaceDeleteQueryBuilder< private objectRecordsPermissions: ObjectsPermissions; private shouldBypassPermissionChecks: boolean; private internalContext: WorkspaceInternalContext; - private authContext?: AuthContext; - private featureFlagMap?: FeatureFlagMap; + private authContext: AuthContext; + private featureFlagMap: FeatureFlagMap; constructor( queryBuilder: DeleteQueryBuilder, objectRecordsPermissions: ObjectsPermissions, internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, - authContext?: AuthContext, - featureFlagMap?: FeatureFlagMap, + authContext: AuthContext, + featureFlagMap: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; @@ -62,6 +63,7 @@ export class WorkspaceDeleteQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ) as this; } @@ -81,26 +83,20 @@ export class WorkspaceDeleteQueryBuilder< this.internalContext, ); - const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder( - this as unknown as WorkspaceSelectQueryBuilder, - this.objectRecordsPermissions, - this.internalContext, - true, - this.authContext, - this.featureFlagMap, - ); + const eventSelectQueryBuilder = computeEventSelectQueryBuilder({ + queryBuilder: this, + authContext: this.authContext, + internalContext: this.internalContext, + featureFlagMap: this.featureFlagMap, + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + }); const tableName = computeTableName( objectMetadata.nameSingular, objectMetadata.isCustom, ); - eventSelectQueryBuilder.expressionMap.wheres = this.expressionMap.wheres; - - eventSelectQueryBuilder.expressionMap.aliases = - this.expressionMap.aliases; - eventSelectQueryBuilder.setParameters(this.getParameters()); - const before = await eventSelectQueryBuilder.getOne(); this.expressionMap.wheres = applyTableAliasOnWhereCondition({ diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts index 11cb616a64..2e48cfe8b2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts @@ -38,8 +38,8 @@ export class WorkspaceInsertQueryBuilder< private objectRecordsPermissions: ObjectsPermissions; private shouldBypassPermissionChecks: boolean; private internalContext: WorkspaceInternalContext; - private authContext?: AuthContext; - private featureFlagMap?: FeatureFlagMap; + private authContext: AuthContext; + private featureFlagMap: FeatureFlagMap; private relationNestedQueries: RelationNestedQueries; private relationNestedConfig: | [RelationConnectQueryConfig[], RelationDisconnectQueryFieldsByEntityIndex] @@ -50,8 +50,8 @@ export class WorkspaceInsertQueryBuilder< objectRecordsPermissions: ObjectsPermissions, internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, - authContext?: AuthContext, - featureFlagMap?: FeatureFlagMap, + authContext: AuthContext, + featureFlagMap: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; @@ -123,6 +123,7 @@ export class WorkspaceInsertQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); const updatedValues = diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts index b87ca1ae8b..975fe0c226 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts @@ -33,15 +33,15 @@ export class WorkspaceSelectQueryBuilder< objectRecordsPermissions: ObjectsPermissions; shouldBypassPermissionChecks: boolean; internalContext: WorkspaceInternalContext; - authContext?: AuthContext; - featureFlagMap?: FeatureFlagMap; + authContext: AuthContext; + featureFlagMap: FeatureFlagMap; constructor( queryBuilder: SelectQueryBuilder, objectRecordsPermissions: ObjectsPermissions, internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, - authContext?: AuthContext, - featureFlagMap?: FeatureFlagMap, + authContext: AuthContext, + featureFlagMap: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts index 5c48080eb2..73ffe7c439 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts @@ -20,9 +20,10 @@ import { } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils'; import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder'; -import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; +import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder'; import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition'; +import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util'; import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util'; @@ -34,16 +35,16 @@ export class WorkspaceSoftDeleteQueryBuilder< private objectRecordsPermissions: ObjectsPermissions; private shouldBypassPermissionChecks: boolean; private internalContext: WorkspaceInternalContext; - private authContext?: AuthContext; - private featureFlagMap?: FeatureFlagMap; + private authContext: AuthContext; + private featureFlagMap: FeatureFlagMap; constructor( queryBuilder: SoftDeleteQueryBuilder, objectRecordsPermissions: ObjectsPermissions, internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, - authContext?: AuthContext, - featureFlagMap?: FeatureFlagMap, + authContext: AuthContext, + featureFlagMap: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; @@ -62,6 +63,7 @@ export class WorkspaceSoftDeleteQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ) as this; } @@ -81,26 +83,21 @@ export class WorkspaceSoftDeleteQueryBuilder< this.internalContext, ); - const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder( - this as unknown as WorkspaceSelectQueryBuilder, - this.objectRecordsPermissions, - this.internalContext, - true, - this.authContext, - this.featureFlagMap, - ); + const beforeEventSelectQueryBuilder = computeEventSelectQueryBuilder({ + queryBuilder: this, + authContext: this.authContext, + internalContext: this.internalContext, + featureFlagMap: this.featureFlagMap, + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + }); const tableName = computeTableName( objectMetadata.nameSingular, objectMetadata.isCustom, ); - eventSelectQueryBuilder.expressionMap.wheres = this.expressionMap.wheres; - eventSelectQueryBuilder.expressionMap.aliases = - this.expressionMap.aliases; - eventSelectQueryBuilder.setParameters(this.getParameters()); - - const before = await eventSelectQueryBuilder.getMany(); + const before = await beforeEventSelectQueryBuilder.getMany(); this.expressionMap.wheres = applyTableAliasOnWhereCondition({ condition: this.expressionMap.wheres, diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts index e416fc8b49..e87e1fd1f1 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts @@ -1,9 +1,11 @@ +import { msg } from '@lingui/core/macro'; +import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; import { type ObjectsPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { + UpdateQueryBuilder, type EntityTarget, type ObjectLiteral, - UpdateQueryBuilder, type UpdateResult, } from 'typeorm'; import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; @@ -28,6 +30,7 @@ import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/reposito import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder'; import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition'; +import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util'; import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util'; @@ -40,8 +43,8 @@ export class WorkspaceUpdateQueryBuilder< private objectRecordsPermissions: ObjectsPermissions; private shouldBypassPermissionChecks: boolean; private internalContext: WorkspaceInternalContext; - private authContext?: AuthContext; - private featureFlagMap?: FeatureFlagMap; + private authContext: AuthContext; + private featureFlagMap: FeatureFlagMap; private relationNestedQueries: RelationNestedQueries; private relationNestedConfig: | [RelationConnectQueryConfig[], RelationDisconnectQueryFieldsByEntityIndex] @@ -56,8 +59,8 @@ export class WorkspaceUpdateQueryBuilder< objectRecordsPermissions: ObjectsPermissions, internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, - authContext?: AuthContext, - featureFlagMap?: FeatureFlagMap, + authContext: AuthContext, + featureFlagMap: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; @@ -103,27 +106,32 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, ); - const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder( - this as unknown as WorkspaceSelectQueryBuilder, - this.objectRecordsPermissions, - this.internalContext, - true, - this.authContext, - this.featureFlagMap, - ); + const eventSelectQueryBuilder = computeEventSelectQueryBuilder({ + queryBuilder: this, + authContext: this.authContext, + internalContext: this.internalContext, + featureFlagMap: this.featureFlagMap, + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + }); const tableName = computeTableName( objectMetadata.nameSingular, objectMetadata.isCustom, ); - eventSelectQueryBuilder.expressionMap.wheres = this.expressionMap.wheres; - eventSelectQueryBuilder.expressionMap.aliases = - this.expressionMap.aliases; - eventSelectQueryBuilder.setParameters(this.getParameters()); - const before = await eventSelectQueryBuilder.getMany(); + if (before.length > QUERY_MAX_RECORDS) { + throw new TwentyORMException( + `Cannot update more than ${QUERY_MAX_RECORDS} records at once`, + TwentyORMExceptionCode.TOO_MANY_RECORDS_TO_UPDATE, + { + userFriendlyMessage: msg`You can only update up to ${QUERY_MAX_RECORDS} records at once.`, + }, + ); + } + this.expressionMap.wheres = applyTableAliasOnWhereCondition({ condition: this.expressionMap.wheres, tableName, @@ -136,6 +144,7 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); if (isDefined(this.relationNestedConfig)) { @@ -238,21 +247,18 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, ); - const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder( - this as unknown as WorkspaceSelectQueryBuilder, - this.objectRecordsPermissions, - this.internalContext, - true, - this.authContext, - this.featureFlagMap, - ); + const eventSelectQueryBuilder = computeEventSelectQueryBuilder({ + queryBuilder: this, + authContext: this.authContext, + internalContext: this.internalContext, + featureFlagMap: this.featureFlagMap, + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + }); eventSelectQueryBuilder.whereInIds( this.manyInputs.map((input) => input.criteria), ); - eventSelectQueryBuilder.expressionMap.aliases = - this.expressionMap.aliases; - eventSelectQueryBuilder.setParameters(this.getParameters()); const beforeRecords = await eventSelectQueryBuilder.getMany(); @@ -270,6 +276,7 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); this.relationNestedConfig = diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts index 38bce46765..25cc5064b6 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts @@ -81,7 +81,7 @@ export class WorkspaceRepository< this.objectRecordsPermissions, this.internalContext, this.shouldBypassPermissionChecks, - this.authContext, + this.authContext ?? {}, this.featureFlagMap, ); } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts new file mode 100644 index 0000000000..485b89cf19 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts @@ -0,0 +1,171 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { type ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util'; +import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock'; +import { getMockObjectMetadataItemWithFieldsMaps } from 'src/utils/__test__/get-object-metadata-item-with-fields-maps.mock'; + +describe('formatTwentyOrmEventToDatabaseBatchEvent', () => { + const objectMetadataItemWithFieldMaps = + getMockObjectMetadataItemWithFieldsMaps({ + id: 'object-id', + workspaceId: 'workspace-id', + nameSingular: 'person', + namePlural: 'people', + indexMetadatas: [], + fieldIdByJoinColumnName: {}, + fieldIdByName: { + name: 'name-id', + age: 'age-id', + fullName: 'fullname-id', + }, + fieldsById: { + 'name-id': getMockFieldMetadataEntity({ + workspaceId: 'workspace-id', + objectMetadataId: 'object-id', + id: 'name-id', + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + isLabelSyncedWithName: true, + isNullable: true, + createdAt: new Date(), + updatedAt: new Date(), + }), + }, + }); + + const mockWorkspaceId = 'workspace-id'; + const mockAuthContext = { + user: { id: 'user-id' }, + workspaceMemberId: 'workspace-member-id', + } as any; + + describe('UPDATED action', () => { + it('should throw TwentyORMException when no matching before entity is found in array of beforeEntities', () => { + const afterEntities = [ + { + id: 'record-1', + name: 'John Doe Updated', + }, + { + id: 'record-2', + name: 'Jane Doe Updated', + }, + ]; + + const beforeEntities = [ + { + id: 'record-1', + name: 'John Doe', + }, + { + id: 'record-3', + name: 'Bob Smith', + }, + ]; + + try { + formatTwentyOrmEventToDatabaseBatchEvent({ + action: DatabaseEventAction.UPDATED, + objectMetadataItem: objectMetadataItemWithFieldMaps, + workspaceId: mockWorkspaceId, + authContext: mockAuthContext, + entities: afterEntities, + beforeEntities: beforeEntities, + }); + } catch (error) { + expect(error).toBeInstanceOf(TwentyORMException); + expect((error as TwentyORMException).code).toBe( + TwentyORMExceptionCode.ORM_EVENT_DATA_CORRUPTED, + ); + expect((error as TwentyORMException).message).toBe( + 'Record mismatch detected while computing event data for UPDATED action', + ); + } + }); + + it('should successfully create update events when matching before entities are found and in the right order', () => { + const afterEntities = [ + { + id: 'record-1', + name: 'John Doe Updated', + }, + { + id: 'record-2', + name: 'Jane Doe Updated', + }, + ]; + + const beforeEntities = [ + { + id: 'record-2', + name: 'Jane Doe', + }, + { + id: 'record-1', + name: 'John Doe', + }, + ]; + + const result = formatTwentyOrmEventToDatabaseBatchEvent({ + action: DatabaseEventAction.UPDATED, + objectMetadataItem: objectMetadataItemWithFieldMaps, + workspaceId: mockWorkspaceId, + authContext: mockAuthContext, + entities: afterEntities, + beforeEntities: beforeEntities, + }); + + expect(result).toBeDefined(); + expect(result?.action).toBe(DatabaseEventAction.UPDATED); + expect(result?.events).toHaveLength(2); + + const updateEvent1 = result?.events[0] as ObjectRecordUpdateEvent< + (typeof afterEntities)[0] + >; + const updateEvent2 = result?.events[1] as ObjectRecordUpdateEvent< + (typeof afterEntities)[1] + >; + + expect(updateEvent1.recordId).toBe('record-1'); + expect(updateEvent2.recordId).toBe('record-2'); + + expect(updateEvent1.properties?.before?.name).toBe('John Doe'); + expect(updateEvent1.properties?.after?.name).toBe('John Doe Updated'); + expect(updateEvent2.properties?.before?.name).toBe('Jane Doe'); + expect(updateEvent2.properties?.after?.name).toBe('Jane Doe Updated'); + }); + + it('should handle single entity (non-array) for both before and after', () => { + const afterEntity = { + id: 'record-1', + name: 'John Doe Updated', + }; + + const beforeEntity = { + id: 'record-1', + name: 'John Doe', + }; + + const result = formatTwentyOrmEventToDatabaseBatchEvent({ + action: DatabaseEventAction.UPDATED, + objectMetadataItem: objectMetadataItemWithFieldMaps, + workspaceId: mockWorkspaceId, + authContext: mockAuthContext, + entities: afterEntity, + beforeEntities: beforeEntity, + }); + + expect(result).toBeDefined(); + expect(result?.action).toBe(DatabaseEventAction.UPDATED); + expect(result?.events).toHaveLength(1); + expect(result?.events[0].recordId).toBe('record-1'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/compute-event-select-query-builder.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/compute-event-select-query-builder.util.ts new file mode 100644 index 0000000000..2d7c9c912b --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/compute-event-select-query-builder.util.ts @@ -0,0 +1,50 @@ +import { type ObjectsPermissions } from 'twenty-shared/types'; +import { type ObjectLiteral } from 'typeorm'; +import { type QueryExpressionMap } from 'typeorm/query-builder/QueryExpressionMap'; + +import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; +import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; + +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder'; +import { type WorkspaceInsertQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-insert-query-builder'; +import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; +import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder'; +import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder'; + +type EventSelectQueryBuilderFactoryArgs = { + queryBuilder: + | WorkspaceUpdateQueryBuilder + | WorkspaceSoftDeleteQueryBuilder + | WorkspaceDeleteQueryBuilder + | WorkspaceInsertQueryBuilder; + authContext: AuthContext; + internalContext: WorkspaceInternalContext; + featureFlagMap: FeatureFlagMap; + expressionMap: QueryExpressionMap; + objectRecordsPermissions: ObjectsPermissions; +}; + +export const computeEventSelectQueryBuilder = ({ + queryBuilder, + authContext, + featureFlagMap, + internalContext, + expressionMap, + objectRecordsPermissions, +}: EventSelectQueryBuilderFactoryArgs): WorkspaceSelectQueryBuilder => { + const eventSelectQueryBuilder = new WorkspaceSelectQueryBuilder( + queryBuilder as unknown as WorkspaceSelectQueryBuilder, + objectRecordsPermissions, + internalContext, + true, + authContext, + featureFlagMap, + ); + + eventSelectQueryBuilder.expressionMap.wheres = expressionMap.wheres; + eventSelectQueryBuilder.expressionMap.aliases = expressionMap.aliases; + eventSelectQueryBuilder.setParameters(expressionMap.parameters); + + return eventSelectQueryBuilder; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts index 51ff6786c8..76b75c05cf 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util.ts @@ -1,5 +1,5 @@ -import { isDefined } from 'twenty-shared/utils'; import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; import type { ObjectLiteral } from 'typeorm'; @@ -13,6 +13,10 @@ import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/t import { ObjectRecordUpsertEvent } from 'src/engine/core-modules/event-emitter/types/object-record-upsert.event'; import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values'; import type { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; import { type DatabaseBatchEventInput } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; export const formatTwentyOrmEventToDatabaseBatchEvent = < @@ -65,15 +69,22 @@ export const formatTwentyOrmEventToDatabaseBatchEvent = < break; case DatabaseEventAction.UPDATED: events = entityArray - .map((after, idx) => { + .map((after) => { if (!beforeEntities) { throw new Error('beforeEntities is required for UPDATED action'); } const before = Array.isArray(beforeEntities) - ? beforeEntities?.[idx] + ? beforeEntities.find((before) => before.id === after.id) : beforeEntities; + if (!isDefined(before)) { + throw new TwentyORMException( + 'Record mismatch detected while computing event data for UPDATED action', + TwentyORMExceptionCode.ORM_EVENT_DATA_CORRUPTED, + ); + } + const diff = objectRecordChangedValues( before, after, @@ -127,7 +138,7 @@ export const formatTwentyOrmEventToDatabaseBatchEvent = < }); break; case DatabaseEventAction.UPSERTED: - events = entityArray.map((after, index) => { + events = entityArray.map((after) => { const event = new ObjectRecordUpsertEvent(); event.userId = authContext?.user?.id; @@ -136,7 +147,7 @@ export const formatTwentyOrmEventToDatabaseBatchEvent = < const before = beforeEntities ? Array.isArray(beforeEntities) - ? beforeEntities[index] + ? beforeEntities.find((before) => before.id === after.id) : beforeEntities : undefined; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts index 571c4d8006..b75e6a68d7 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts @@ -13,6 +13,7 @@ export const twentyORMGraphqlApiExceptionHandler = ( case TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND: case TwentyORMExceptionCode.CONNECT_NOT_ALLOWED: case TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR: + case TwentyORMExceptionCode.TOO_MANY_RECORDS_TO_UPDATE: throw new UserInputError(error.message, { userFriendlyMessage: error.userFriendlyMessage, }); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts index ff0c7970e4..65828a1a50 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts @@ -386,6 +386,7 @@ describe('Field update permissions restrictions', () => { objectMetadataPluralName: 'companies', gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES, data: { name: 'UpdatedCompany' }, + filter: { name: { ilike: '%aa%' } }, }); const response =