From f8fa709abf1291ab96b2411d46301a2b5204dfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 31 Dec 2025 10:24:50 +0100 Subject: [PATCH] refactor: Migrate CRUD services to use Common API (#16869) This PR migrates the workflow CRUD services to use the Common API (CommonQueryRunners) instead of directly accessing TwentyORM. ## Changes - Created CommonApiContextBuilderService to build context for Common API - Migrated CreateRecordService to use CommonCreateOneQueryRunnerService - Migrated UpdateRecordService to use CommonUpdateOneQueryRunnerService - Migrated DeleteRecordService to use CommonDeleteOneQueryRunnerService - Migrated FindRecordsService to use CommonFindManyQueryRunnerService - Migrated UpsertRecordService to use Common API with upsert flag - Removed unused get-selected-columns-from-restricted-fields.util.ts - Updated module dependencies ## Benefits - Consistent permission checking via Common API - Query hooks (before/after execution) - Automatic input transformation - Same behavior as REST/GraphQL APIs - Reduced code duplication --- .../__tests__/api-key.service.spec.ts | 2 +- .../application/application.entity.ts | 2 +- .../entities/billing-customer.entity.ts | 2 +- .../record-crud/record-crud.module.ts | 20 +- .../common-api-context-builder.service.ts | 158 ++++++++++ .../services/create-record.service.ts | 184 ++++-------- .../services/delete-record.service.ts | 144 ++++----- .../services/find-records.service.ts | 275 ++++-------------- .../services/update-record.service.ts | 205 +++++-------- .../services/upsert-record.service.ts | 208 +++---------- ...ted-columns-from-restricted-fields.util.ts | 29 -- .../remove-undefined-from-record.util.ts | 34 +++ .../providers/database-tool.provider.ts | 19 ++ .../services/tool-registry.service.ts | 21 +- .../user-workspace/user-workspace.entity.ts | 2 +- .../workspace/workspace.entity.ts | 4 +- .../services/chat-execution.service.ts | 1 + .../services/field-metadata.service.ts | 4 +- .../index-metadata/index-metadata.entity.ts | 2 +- .../object-metadata/object-metadata.entity.ts | 2 +- .../field-permission.entity.ts | 2 +- .../object-permission.entity.ts | 2 +- .../entities/page-layout-tab.entity.ts | 2 +- .../entities/page-layout-widget.entity.ts | 6 +- .../entities/page-layout.entity.ts | 2 +- .../role-target/role-target.entity.ts | 2 +- .../metadata-modules/role/role.entity.ts | 2 +- .../search-field-metadata.entity.ts | 2 +- .../view-field/entities/view-field.entity.ts | 2 +- .../entities/view-filter-group.entity.ts | 2 +- .../entities/view-filter.entity.ts | 4 +- .../view-group/entities/view-group.entity.ts | 2 +- .../view-sort/entities/view-sort.entity.ts | 2 +- .../view/entities/view.entity.ts | 2 +- .../types/syncable-entity.interface.ts | 2 +- .../workflow-execution-context.service.ts | 28 +- .../ai-agent/ai-agent-action.module.ts | 2 + .../record-crud/record-crud-action.module.ts | 2 + ...orkflow-database-event-trigger.listener.ts | 2 +- 39 files changed, 567 insertions(+), 821 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts index 77ea3dd752..566bd806fb 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts @@ -30,7 +30,7 @@ describe('ApiKeyService', () => { const mockApiKey: ApiKeyEntity = { id: mockApiKeyId, name: 'Test API Key', - expiresAt: new Date('2025-12-31'), + expiresAt: new Date('2030-12-31'), revokedAt: undefined, workspaceId: mockWorkspaceId, createdAt: new Date('2024-01-01'), diff --git a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts index dece847590..f18a895e10 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts @@ -8,7 +8,7 @@ import { Index, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-customer.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-customer.entity.ts index 93c0a22513..76840eb2fa 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-customer.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-customer.entity.ts @@ -10,7 +10,7 @@ import { Index, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts index 56d4c8b3f7..5a35bf915d 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts @@ -1,23 +1,27 @@ import { Module } from '@nestjs/common'; +import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module'; +import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service'; -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 { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; -import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ imports: [ - TwentyORMModule, - RecordPositionModule, - RecordTransformerModule, - WorkflowCommonModule, + CoreCommonApiModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, + WorkspaceCacheModule, + UserRoleModule, + ApiKeyModule, ], providers: [ + CommonApiContextBuilderService, CreateRecordService, UpdateRecordService, DeleteRecordService, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts new file mode 100644 index 0000000000..bef7386f1c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts @@ -0,0 +1,158 @@ +import { Injectable } from '@nestjs/common'; + +import { type ObjectsPermissions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; + +import { type CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type'; +import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; +import { getAllSelectableFields } from 'src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util'; +import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +export type CommonApiContext = { + queryRunnerContext: CommonBaseQueryRunnerContext; + selectedFields: CommonSelectedFields; + flatObjectMetadata: FlatObjectMetadata; + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + objectsPermissions: ObjectsPermissions; +}; + +@Injectable() +export class CommonApiContextBuilderService { + constructor( + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly userRoleService: UserRoleService, + private readonly apiKeyRoleService: ApiKeyRoleService, + ) {} + + async build({ + authContext, + objectName, + }: { + authContext: WorkspaceAuthContext; + objectName: string; + }): Promise { + const workspaceId = authContext.workspace.id; + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + if (!isDefined(flatObjectMetadataMaps)) { + throw new RecordCrudException( + 'Object metadata not found for workspace', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const { idByNameSingular } = buildObjectIdByNameMaps( + flatObjectMetadataMaps, + ); + const objectId = idByNameSingular[objectName]; + + if (!isDefined(objectId)) { + throw new RecordCrudException( + `Object ${objectName} not found`, + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const flatObjectMetadata = flatObjectMetadataMaps.byId[objectId]; + + if (!isDefined(flatObjectMetadata)) { + throw new RecordCrudException( + `Object metadata for ${objectName} not found`, + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const objectsPermissions = await this.getObjectsPermissions(authContext); + + const restrictedFields = + objectsPermissions[flatObjectMetadata.id]?.restrictedFields ?? {}; + + const selectedFields = getAllSelectableFields({ + restrictedFields, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + return { + queryRunnerContext: { + authContext, + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + objectIdByNameSingular: idByNameSingular, + }, + selectedFields, + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + objectsPermissions, + }; + } + + private async getObjectsPermissions( + authContext: WorkspaceAuthContext, + ): Promise { + const workspaceId = authContext.workspace.id; + let roleId: string; + + if (isDefined(authContext.apiKey)) { + roleId = await this.apiKeyRoleService.getRoleIdForApiKeyId( + authContext.apiKey.id, + workspaceId, + ); + } else if ( + isDefined(authContext.application?.defaultServerlessFunctionRoleId) + ) { + roleId = authContext.application.defaultServerlessFunctionRoleId; + } else if (isDefined(authContext.userWorkspaceId)) { + const userWorkspaceRoleId = + await this.userRoleService.getRoleIdForUserWorkspace({ + userWorkspaceId: authContext.userWorkspaceId, + workspaceId, + }); + + if (!isDefined(userWorkspaceRoleId)) { + throw new RecordCrudException( + 'No role found for user workspace', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + roleId = userWorkspaceRoleId; + } else { + throw new RecordCrudException( + 'Invalid auth context - no authentication mechanism found', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const { rolesPermissions } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'rolesPermissions', + ]); + + return rolesPermissions[roleId] ?? {}; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts index 7e698806fa..8172881cf7 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts @@ -1,158 +1,92 @@ import { Injectable, Logger } from '@nestjs/common'; import { FieldActorSource } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; +import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; import { RecordCrudException, RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; import { type CreateRecordParams } from 'src/engine/core-modules/record-crud/types/create-record-params.type'; import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; -import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util'; -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 { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; -import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @Injectable() export class CreateRecordService { private readonly logger = new Logger(CreateRecordService.name); constructor( - private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, - private readonly recordPositionService: RecordPositionService, - private readonly recordInputTransformerService: RecordInputTransformerService, + private readonly commonCreateOneRunner: CommonCreateOneQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, ) {} async execute(params: CreateRecordParams): Promise { - const { objectName, objectRecord, authContext, rolePermissionConfig } = - params; - - const workspaceId = authContext.workspace.id; + const { objectName, objectRecord, authContext } = params; try { - return await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + const { + queryRunnerContext, + selectedFields, + flatObjectMetadata, + flatFieldMetadataMaps, + } = await this.commonApiContextBuilder.build({ authContext, - async () => { - const repository = await this.globalWorkspaceOrmManager.getRepository( - workspaceId, - objectName, - rolePermissionConfig, - ); + objectName, + }); - const { - flatObjectMetadataMaps, - flatFieldMetadataMaps, - objectIdByNameSingular, - } = repository.internalContext; + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: flatObjectMetadata.nameSingular, + isSystem: flatObjectMetadata.isSystem, + }) + ) { + throw new RecordCrudException( + 'Failed to create: Object cannot be created by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } - const objectId = objectIdByNameSingular[objectName]; + // Pass createdBy explicitly if provided (for workflows) + // Common API hook will also inject createdBy from authContext if available + const actorMetadata = params.createdBy ?? { + source: FieldActorSource.WORKFLOW, + name: 'Workflow', + }; - if (!isDefined(objectId)) { - throw new RecordCrudException( - `Object ${objectName} not found`, - RecordCrudExceptionCode.INVALID_REQUEST, - ); - } + // Clean undefined values from the record data (including nested composite fields) + // This prevents validation errors for partial composite field inputs + const cleanedRecord = removeUndefinedFromRecord(objectRecord); + const dataWithActor = { ...cleanedRecord, createdBy: actorMetadata }; - const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatObjectMetadataMaps, - flatEntityId: objectId, - }); - - if ( - !canObjectBeManagedByWorkflow({ - nameSingular: flatObjectMetadata.nameSingular, - isSystem: flatObjectMetadata.isSystem, - }) - ) { - throw new RecordCrudException( - 'Failed to create: Object cannot be created by workflow', - RecordCrudExceptionCode.INVALID_REQUEST, - ); - } - - const position = await this.recordPositionService.buildRecordPosition( - { - value: 'first', - objectMetadata: flatObjectMetadata, - workspaceId, - }, - ); - - const { fieldIdByName, fieldIdByJoinColumnName } = - buildFieldMapsFromFlatObjectMetadata( - flatFieldMetadataMaps, - flatObjectMetadata, - ); - - const validObjectRecord = Object.fromEntries( - Object.entries(objectRecord).filter( - ([key]) => - isDefined(fieldIdByName[key]) || - isDefined(fieldIdByJoinColumnName[key]), - ), - ); - - const transformedObjectRecord = - await this.recordInputTransformerService.process({ - recordInput: validObjectRecord, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - const restrictedFields = - repository.objectRecordsPermissions?.[flatObjectMetadata.id] - ?.restrictedFields; - - const selectedColumns = getSelectedColumnsFromRestrictedFields( - restrictedFields, - flatObjectMetadata, - flatFieldMetadataMaps, - ); - - const actorMetadata = params.createdBy ?? { - source: FieldActorSource.WORKFLOW, - name: 'Workflow', - }; - - const insertResult = await repository.insert( - { - ...transformedObjectRecord, - position, - createdBy: actorMetadata, - // updatedBy: actorMetadata, - }, - undefined, - selectedColumns, - ); - - const [createdRecord] = insertResult.generatedMaps; - - this.logger.log(`Record created successfully in ${objectName}`); - - return { - success: true, - message: `Record created successfully in ${objectName}`, - result: createdRecord, - recordReferences: [ - { - objectNameSingular: objectName, - recordId: createdRecord.id, - displayName: getRecordDisplayName( - { ...transformedObjectRecord, ...createdRecord }, - flatObjectMetadata, - flatFieldMetadataMaps, - ), - }, - ], - }; + const createdRecord = await this.commonCreateOneRunner.execute( + { + data: dataWithActor, + selectedFields, }, + queryRunnerContext, ); + + this.logger.log(`Record created successfully in ${objectName}`); + + return { + success: true, + message: `Record created successfully in ${objectName}`, + result: createdRecord, + recordReferences: [ + { + objectNameSingular: objectName, + recordId: createdRecord.id, + displayName: getRecordDisplayName( + createdRecord, + flatObjectMetadata, + flatFieldMetadataMaps, + ), + }, + ], + }; } catch (error) { if (error instanceof RecordCrudException) { return { diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/delete-record.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/delete-record.service.ts index 4669255fab..d389ee9087 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/delete-record.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/delete-record.service.ts @@ -3,33 +3,28 @@ import { Injectable, Logger } from '@nestjs/common'; import { isDefined, isValidUuid } from 'twenty-shared/utils'; import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; +import { CommonDeleteOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-one-query-runner.service'; +import { CommonDestroyOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-one-query-runner.service'; import { RecordCrudException, RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; import { type DeleteRecordParams } from 'src/engine/core-modules/record-crud/types/delete-record-params.type'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; -import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @Injectable() export class DeleteRecordService { private readonly logger = new Logger(DeleteRecordService.name); constructor( - private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly commonDeleteOneRunner: CommonDeleteOneQueryRunnerService, + private readonly commonDestroyOneRunner: CommonDestroyOneQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, ) {} async execute(params: DeleteRecordParams): Promise { - const { - objectName, - objectRecordId, - authContext, - rolePermissionConfig, - soft = true, - } = params; - - const workspaceId = authContext.workspace.id; + const { objectName, objectRecordId, authContext, soft = true } = params; if (!isDefined(objectRecordId) || !isValidUuid(objectRecordId)) { return { @@ -40,90 +35,59 @@ export class DeleteRecordService { } try { - return await this.globalWorkspaceOrmManager.executeInWorkspaceContext( - authContext, - async () => { - const repository = await this.globalWorkspaceOrmManager.getRepository( - workspaceId, - objectName, - rolePermissionConfig, - ); + const { queryRunnerContext, selectedFields, flatObjectMetadata } = + await this.commonApiContextBuilder.build({ + authContext, + objectName, + }); - const { flatObjectMetadataMaps, objectIdByNameSingular } = - repository.internalContext; + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: flatObjectMetadata.nameSingular, + isSystem: flatObjectMetadata.isSystem, + }) + ) { + throw new RecordCrudException( + 'Failed to delete: Object cannot be deleted by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } - const objectId = objectIdByNameSingular[objectName]; + if (soft) { + const deletedRecord = await this.commonDeleteOneRunner.execute( + { + id: objectRecordId, + selectedFields, + }, + queryRunnerContext, + ); - if (!isDefined(objectId)) { - throw new RecordCrudException( - `Object ${objectName} not found`, - RecordCrudExceptionCode.INVALID_REQUEST, - ); - } + this.logger.log(`Record soft deleted successfully from ${objectName}`); - const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatObjectMetadataMaps, - flatEntityId: objectId, - }); + return { + success: true, + message: `Record soft deleted successfully from ${objectName}`, + result: deletedRecord, + }; + } else { + const destroyedRecord = await this.commonDestroyOneRunner.execute( + { + id: objectRecordId, + selectedFields, + }, + queryRunnerContext, + ); - if ( - !canObjectBeManagedByWorkflow({ - nameSingular: flatObjectMetadata.nameSingular, - isSystem: flatObjectMetadata.isSystem, - }) - ) { - throw new RecordCrudException( - 'Failed to delete: Object cannot be deleted by workflow', - RecordCrudExceptionCode.INVALID_REQUEST, - ); - } + this.logger.log( + `Record permanently deleted successfully from ${objectName}`, + ); - const objectRecord = await repository.findOne({ - where: { - id: objectRecordId, - }, - }); - - if (!objectRecord) { - throw new RecordCrudException( - `Failed to delete: Record ${objectName} with id ${objectRecordId} not found`, - RecordCrudExceptionCode.RECORD_NOT_FOUND, - ); - } - - if (soft) { - const columnsToReturnForSoftDelete: string[] = []; - - await repository.softDelete( - objectRecordId, - undefined, - columnsToReturnForSoftDelete, - ); - - this.logger.log( - `Record soft deleted successfully from ${objectName}`, - ); - - return { - success: true, - message: `Record soft deleted successfully from ${objectName}`, - result: objectRecord, - }; - } else { - await repository.remove(objectRecord); - - this.logger.log( - `Record permanently deleted successfully from ${objectName}`, - ); - - return { - success: true, - message: `Record permanently deleted successfully from ${objectName}`, - result: { id: objectRecordId }, - }; - } - }, - ); + return { + success: true, + message: `Record permanently deleted successfully from ${objectName}`, + result: destroyedRecord, + }; + } } catch (error) { if (error instanceof RecordCrudException) { return { diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts index c352e94b6c..052d50b840 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts @@ -1,40 +1,24 @@ import { Injectable, Logger } from '@nestjs/common'; -import isEmpty from 'lodash.isempty'; import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; -import { OrderByDirection } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { type ObjectLiteral } from 'typeorm'; +import { OrderByDirection, type ObjectRecord } from 'twenty-shared/types'; -import { - type ObjectRecordFilter, - type ObjectRecordOrderBy, -} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; -import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; -import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils'; -import { - RecordCrudException, - RecordCrudExceptionCode, -} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type'; import { type FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type'; import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; -import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; -import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; -import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; -import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; @Injectable() export class FindRecordsService { private readonly logger = new Logger(FindRecordsService.name); constructor( - private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly commonFindManyRunner: CommonFindManyQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, ) {} async execute( @@ -47,91 +31,57 @@ export class FindRecordsService { limit, offset = 0, authContext, - rolePermissionConfig, } = params; - const workspaceId = authContext.workspace.id; - try { - return await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + const { + queryRunnerContext, + selectedFields, + flatObjectMetadata, + flatFieldMetadataMaps, + } = await this.commonApiContextBuilder.build({ authContext, - async () => { - const repository = await this.globalWorkspaceOrmManager.getRepository( - workspaceId, - objectName, - rolePermissionConfig, - ); + objectName, + }); - const { - flatObjectMetadataMaps, - flatFieldMetadataMaps, - objectIdByNameSingular, - } = repository.internalContext; + // Add id to orderBy for consistent pagination + const orderByWithIdCondition: ObjectRecordOrderBy = [ + ...(orderBy ?? []).filter((item) => item !== undefined), + { id: OrderByDirection.AscNullsFirst }, + ]; - const objectId = objectIdByNameSingular[objectName]; - - if (!isDefined(objectId)) { - throw new RecordCrudException( - `Object ${objectName} not found`, - RecordCrudExceptionCode.INVALID_REQUEST, - ); - } - - const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatObjectMetadataMaps, - flatEntityId: objectId, - }); - - const graphqlQueryParser = new GraphqlQueryParser( - flatObjectMetadata, - flatObjectMetadataMaps, - flatFieldMetadataMaps, - ); - - const records = await this.getObjectRecords({ - objectName, - filter, - orderBy, - limit, - offset, - repository, - graphqlQueryParser, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - const totalCount = await this.getTotalCount({ - objectName, - filter, - repository, - graphqlQueryParser, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - this.logger.log(`Found ${records.length} records in ${objectName}`); - - const recordReferences = records.map((record) => ({ - objectNameSingular: objectName, - recordId: record.id as string, - displayName: getRecordDisplayName( - record, - flatObjectMetadata, - flatFieldMetadataMaps, - ), - })); - - return { - success: true, - message: `Found ${records.length} ${objectName} records`, - result: { - records, - count: totalCount, - }, - recordReferences, - }; + const { records, totalCount } = await this.commonFindManyRunner.execute( + { + filter, + orderBy: orderByWithIdCondition, + first: limit ? Math.min(limit, QUERY_MAX_RECORDS) : QUERY_MAX_RECORDS, + offset, + selectedFields: { ...selectedFields, totalCount: true }, }, + queryRunnerContext, ); + + this.logger.log(`Found ${records.length} records in ${objectName}`); + + const recordReferences = records.map((record: ObjectRecord) => ({ + objectNameSingular: objectName, + recordId: record.id as string, + displayName: getRecordDisplayName( + record, + flatObjectMetadata, + flatFieldMetadataMaps, + ), + })); + + return { + success: true, + message: `Found ${records.length} ${objectName} records`, + result: { + records, + count: totalCount, + }, + recordReferences, + }; } catch (error) { this.logger.error(`Failed to find records: ${error}`); @@ -143,131 +93,4 @@ export class FindRecordsService { }; } } - - private applyRestrictedFieldsToQueryBuilder( - queryBuilder: WorkspaceSelectQueryBuilder, - repository: WorkspaceRepository, - flatObjectMetadata: FlatObjectMetadata, - flatFieldMetadataMaps: FlatEntityMaps, - ): WorkspaceSelectQueryBuilder { - const restrictedFields = - repository.objectRecordsPermissions?.[flatObjectMetadata.id] - ?.restrictedFields; - - if (!restrictedFields || isEmpty(restrictedFields)) { - return queryBuilder; - } - - const selectableFields = getAllSelectableColumnNames({ - restrictedFields, - objectMetadata: { - objectMetadataMapItem: flatObjectMetadata, - flatFieldMetadataMaps, - }, - }); - - return queryBuilder.setFindOptions({ - // @ts-expect-error - TypeORM typing limitation with dynamic select fields - select: selectableFields, - }); - } - - private async getObjectRecords({ - objectName, - filter, - orderBy, - limit, - offset, - repository, - graphqlQueryParser, - flatObjectMetadata, - flatFieldMetadataMaps, - }: { - objectName: string; - filter: - | Partial - | Partial[] - | undefined; - orderBy: Partial | undefined; - limit: number | undefined; - offset: number; - repository: WorkspaceRepository; - graphqlQueryParser: GraphqlQueryParser; - flatObjectMetadata: FlatObjectMetadata; - flatFieldMetadataMaps: FlatEntityMaps; - }): Promise { - const queryBuilder = repository.createQueryBuilder(objectName); - - const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( - queryBuilder, - objectName, - filter ?? {}, - ); - - const orderByWithIdCondition: ObjectRecordOrderBy = [ - ...(orderBy ?? []).filter((item) => item !== undefined), - { id: OrderByDirection.AscNullsFirst }, - ]; - - const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder( - withFilterQueryBuilder, - orderByWithIdCondition, - objectName, - true, - ); - - const queryBuilderWithSelect = this.applyRestrictedFieldsToQueryBuilder( - withOrderByQueryBuilder, - repository, - flatObjectMetadata, - flatFieldMetadataMaps, - ); - - return queryBuilderWithSelect - .skip(offset) - .take(limit ? Math.min(limit, QUERY_MAX_RECORDS) : QUERY_MAX_RECORDS) - .getMany(); - } - - private async getTotalCount({ - objectName, - filter, - repository, - graphqlQueryParser, - flatObjectMetadata, - flatFieldMetadataMaps, - }: { - objectName: string; - filter: - | Partial - | Partial[] - | undefined; - repository: WorkspaceRepository; - graphqlQueryParser: GraphqlQueryParser; - flatObjectMetadata: FlatObjectMetadata; - flatFieldMetadataMaps: FlatEntityMaps; - }): Promise { - const countQueryBuilder = repository.createQueryBuilder(objectName); - - const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder( - countQueryBuilder, - objectName, - filter ?? {}, - ); - - const withDeletedCountQueryBuilder = - graphqlQueryParser.applyDeletedAtToBuilder( - withFilterCountQueryBuilder, - filter ?? {}, - ); - - const queryBuilderWithSelect = this.applyRestrictedFieldsToQueryBuilder( - withDeletedCountQueryBuilder, - repository, - flatObjectMetadata, - flatFieldMetadataMaps, - ); - - return queryBuilderWithSelect.getCount(); - } } diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts index 0ea1b4987c..8b8b161269 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts @@ -1,28 +1,26 @@ import { Injectable, Logger } from '@nestjs/common'; -import deepEqual from 'deep-equal'; import { isDefined, isValidUuid } from 'twenty-shared/utils'; import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; +import { CommonUpdateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-one-query-runner.service'; import { RecordCrudException, RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; import { type UpdateRecordParams } from 'src/engine/core-modules/record-crud/types/update-record-params.type'; import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; -import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util'; -import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; -import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @Injectable() export class UpdateRecordService { private readonly logger = new Logger(UpdateRecordService.name); constructor( - private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, - private readonly recordInputTransformerService: RecordInputTransformerService, + private readonly commonUpdateOneRunner: CommonUpdateOneQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, ) {} async execute(params: UpdateRecordParams): Promise { @@ -32,12 +30,8 @@ export class UpdateRecordService { objectRecord, fieldsToUpdate, authContext, - rolePermissionConfig, - // updatedBy, } = params; - const workspaceId = authContext.workspace.id; - if (!isDefined(objectRecordId) || !isValidUuid(objectRecordId)) { return { success: false, @@ -47,144 +41,81 @@ export class UpdateRecordService { } try { - return await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + const { + queryRunnerContext, + selectedFields, + flatObjectMetadata, + flatFieldMetadataMaps, + } = await this.commonApiContextBuilder.build({ authContext, - async () => { - const repository = await this.globalWorkspaceOrmManager.getRepository( - workspaceId, - objectName, - rolePermissionConfig, - ); + objectName, + }); - const { - flatObjectMetadataMaps, - flatFieldMetadataMaps, - objectIdByNameSingular, - } = repository.internalContext; + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: flatObjectMetadata.nameSingular, + isSystem: flatObjectMetadata.isSystem, + }) + ) { + throw new RecordCrudException( + 'Failed to update: Object cannot be updated by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } - const objectId = objectIdByNameSingular[objectName]; + const fieldsToUpdateArray = fieldsToUpdate ?? Object.keys(objectRecord); - if (!isDefined(objectId)) { - throw new RecordCrudException( - `Object ${objectName} not found`, - RecordCrudExceptionCode.INVALID_REQUEST, - ); + if (fieldsToUpdateArray.length === 0) { + return { + success: true, + message: 'No fields to update', + result: undefined, + }; + } + + // Filter objectRecord to only include fieldsToUpdate + const filteredObjectRecord = Object.keys(objectRecord).reduce( + (acc, key) => { + if (fieldsToUpdateArray.includes(key)) { + return { ...acc, [key]: objectRecord[key] }; } - const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatObjectMetadataMaps, - flatEntityId: objectId, - }); + return acc; + }, + {}, + ); - const restrictedFields = - repository.objectRecordsPermissions?.[flatObjectMetadata.id] - ?.restrictedFields; + // Clean undefined values from the record data (including nested composite fields) + // This prevents validation errors for partial composite field inputs + const cleanedRecord = removeUndefinedFromRecord(filteredObjectRecord); - const selectedColumns = getSelectedColumnsFromRestrictedFields( - restrictedFields, - flatObjectMetadata, - flatFieldMetadataMaps, - ); + const updatedRecord = await this.commonUpdateOneRunner.execute( + { + id: objectRecordId, + data: cleanedRecord, + selectedFields, + }, + queryRunnerContext, + ); - const previousObjectRecord = await repository.findOne({ - where: { - id: objectRecordId, - }, - select: selectedColumns, - }); + this.logger.log(`Record updated successfully in ${objectName}`); - if (!previousObjectRecord) { - throw new RecordCrudException( - `Failed to update: Record ${objectName} with id ${objectRecordId} not found`, - RecordCrudExceptionCode.RECORD_NOT_FOUND, - ); - } - - const fieldsToUpdateArray = - fieldsToUpdate || Object.keys(objectRecord); - - if (fieldsToUpdateArray.length === 0) { - return { - success: true, - message: 'No fields to update', - result: previousObjectRecord, - }; - } - - if ( - !canObjectBeManagedByWorkflow({ - nameSingular: flatObjectMetadata.nameSingular, - isSystem: flatObjectMetadata.isSystem, - }) - ) { - throw new RecordCrudException( - 'Failed to update: Object cannot be updated by workflow', - RecordCrudExceptionCode.INVALID_REQUEST, - ); - } - - const objectRecordWithFilteredFields = Object.keys( - objectRecord, - ).reduce((acc, key) => { - if (fieldsToUpdateArray.includes(key)) { - return { - ...acc, - [key]: objectRecord[key], - }; - } - - return acc; - }, {}); - - const transformedObjectRecord = - await this.recordInputTransformerService.process({ - recordInput: objectRecordWithFilteredFields, + return { + success: true, + message: `Record updated successfully in ${objectName}`, + result: updatedRecord, + recordReferences: [ + { + objectNameSingular: objectName, + recordId: objectRecordId, + displayName: getRecordDisplayName( + updatedRecord, flatObjectMetadata, flatFieldMetadataMaps, - }); - - const updatedObjectRecord = { - ...previousObjectRecord, - ...objectRecordWithFilteredFields, - }; - - if (!deepEqual(updatedObjectRecord, previousObjectRecord)) { - await repository.update( - objectRecordId, - { - ...transformedObjectRecord, - /* updatedBy: updatedBy ?? { - source: FieldActorSource.WORKFLOW, - name: 'Workflow', - workspaceMemberId: null, - context: {}, - },*/ - }, - undefined, - selectedColumns, - ); - } - - this.logger.log(`Record updated successfully in ${objectName}`); - - return { - success: true, - message: `Record updated successfully in ${objectName}`, - result: updatedObjectRecord, - recordReferences: [ - { - objectNameSingular: objectName, - recordId: objectRecordId, - displayName: getRecordDisplayName( - updatedObjectRecord, - flatObjectMetadata, - flatFieldMetadataMaps, - ), - }, - ], - }; - }, - ); + ), + }, + ], + }; } catch (error) { if (error instanceof RecordCrudException) { return { diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts index 792704c0b9..3f01cdbc33 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts @@ -1,197 +1,69 @@ import { Injectable, Logger } from '@nestjs/common'; -import { isDefined } from 'twenty-shared/utils'; import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; +import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; import { RecordCrudException, RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; import { type UpsertRecordParams } from 'src/engine/core-modules/record-crud/types/upsert-record-params.type'; -import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util'; -import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; -import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; -import { getCompositeTypeOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/get-composite-type-or-throw.util'; -import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; -import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @Injectable() export class UpsertRecordService { private readonly logger = new Logger(UpsertRecordService.name); constructor( - private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, - private readonly recordInputTransformerService: RecordInputTransformerService, + private readonly commonCreateOneRunner: CommonCreateOneQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, ) {} async execute(params: UpsertRecordParams): Promise { - const { objectName, objectRecord, authContext, rolePermissionConfig } = - params; - - const workspaceId = authContext.workspace.id; + const { objectName, objectRecord, authContext } = params; try { - return await this.globalWorkspaceOrmManager.executeInWorkspaceContext( - authContext, - async () => { - const repository = await this.globalWorkspaceOrmManager.getRepository( - workspaceId, - objectName, - rolePermissionConfig, - ); + const { queryRunnerContext, selectedFields, flatObjectMetadata } = + await this.commonApiContextBuilder.build({ + authContext, + objectName, + }); - const fieldsToUpdateArray = Object.keys(objectRecord).filter( - (field) => isDefined(objectRecord[field]), - ); + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: flatObjectMetadata.nameSingular, + isSystem: flatObjectMetadata.isSystem, + }) + ) { + throw new RecordCrudException( + 'Failed to update: Object cannot be updated by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } - const { - flatObjectMetadataMaps, - flatFieldMetadataMaps, - objectIdByNameSingular, - } = repository.internalContext; + // Clean undefined values from the record data (including nested composite fields) + // This prevents validation errors for partial composite field inputs + const cleanedRecord = removeUndefinedFromRecord(objectRecord); - const objectId = objectIdByNameSingular[objectName]; - - if (!isDefined(objectId)) { - throw new RecordCrudException( - `Object ${objectName} not found`, - RecordCrudExceptionCode.INVALID_REQUEST, - ); - } - - const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityMaps: flatObjectMetadataMaps, - flatEntityId: objectId, - }); - - if ( - !canObjectBeManagedByWorkflow({ - nameSingular: flatObjectMetadata.nameSingular, - isSystem: flatObjectMetadata.isSystem, - }) - ) { - throw new RecordCrudException( - 'Failed to update: Object cannot be updated by workflow', - RecordCrudExceptionCode.INVALID_REQUEST, - ); - } - - const objectRecordWithFilteredFields = Object.keys( - objectRecord, - ).reduce((acc, key) => { - if (fieldsToUpdateArray.includes(key)) { - return { - ...acc, - [key]: objectRecord[key], - }; - } - - return acc; - }, {}); - - const transformedObjectRecord = - await this.recordInputTransformerService.process({ - recordInput: objectRecordWithFilteredFields, - flatObjectMetadata, - flatFieldMetadataMaps, - }); - - const { fieldIdByName, fieldIdByJoinColumnName } = - buildFieldMapsFromFlatObjectMetadata( - flatFieldMetadataMaps, - flatObjectMetadata, - ); - - const uniqueFieldsToUpdate = fieldsToUpdateArray - .map( - (fieldName) => - fieldIdByName[fieldName] || fieldIdByJoinColumnName[fieldName], - ) - .map((fieldId) => flatFieldMetadataMaps.byId[fieldId]) - .filter(isDefined) - .filter((field) => field.isUnique || field.name === 'id'); - - const conflictPathsUniqueFieldsToUpdate = - uniqueFieldsToUpdate.flatMap((field) => { - if (isCompositeFieldMetadataType(field.type)) { - const compositeType = getCompositeTypeOrThrow(field.type); - - const uniqueProperties = compositeType.properties.filter( - (prop) => prop.isIncludedInUniqueConstraint, - ); - - const propertiesToUse = - uniqueProperties.length > 0 - ? uniqueProperties - : [compositeType.properties[0]]; - - return propertiesToUse.map((prop) => - computeCompositeColumnName(field, prop), - ); - } - - return [field.name]; - }); - - const conflictPaths = - conflictPathsUniqueFieldsToUpdate.length > 0 - ? conflictPathsUniqueFieldsToUpdate - : ['id']; - - const restrictedFields = - repository.objectRecordsPermissions?.[flatObjectMetadata.id] - ?.restrictedFields; - - const selectedColumns = getSelectedColumnsFromRestrictedFields( - restrictedFields, - flatObjectMetadata, - flatFieldMetadataMaps, - ); - - const upsertResult = await repository.upsert( - transformedObjectRecord, - { - conflictPaths: conflictPaths, - }, - undefined, - selectedColumns, - ); - - const upsertedRecordId = upsertResult.identifiers?.[0].id; - - if (!isDefined(upsertedRecordId)) { - throw new RecordCrudException( - `Failed to upsert record in ${objectName}`, - RecordCrudExceptionCode.RECORD_UPSERT_FAILED, - ); - } - - const upsertedRecord = await repository.findOne({ - where: { - id: upsertedRecordId, - }, - select: selectedColumns, - }); - - if (!upsertedRecord) { - throw new RecordCrudException( - `Record not found after upsert with id ${upsertedRecordId} in ${objectName}`, - RecordCrudExceptionCode.RECORD_UPSERT_FAILED, - ); - } - - this.logger.log(`Record upserted successfully in ${objectName}`); - - return { - success: true, - message: `Record upserted successfully in ${objectName}`, - result: upsertedRecord, - }; + // Use Common API with upsert flag - it handles conflict detection automatically + const upsertedRecord = await this.commonCreateOneRunner.execute( + { + data: cleanedRecord, + selectedFields, + upsert: true, }, + queryRunnerContext, ); + + this.logger.log(`Record upserted successfully in ${objectName}`); + + return { + success: true, + message: `Record upserted successfully in ${objectName}`, + result: upsertedRecord, + }; } catch (error) { if (error instanceof RecordCrudException) { return { diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util.ts deleted file mode 100644 index 0c3ac93abb..0000000000 --- a/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util.ts +++ /dev/null @@ -1,29 +0,0 @@ -import isEmpty from 'lodash.isempty'; -import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; - -import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils'; -import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; - -export const getSelectedColumnsFromRestrictedFields = ( - restrictedFields: RestrictedFieldsPermissions | undefined, - flatObjectMetadata: FlatObjectMetadata, - flatFieldMetadataMaps: FlatEntityMaps, -): string[] | undefined => { - if (!restrictedFields || isEmpty(restrictedFields)) { - return undefined; - } - - const selectableFields = getAllSelectableColumnNames({ - restrictedFields, - objectMetadata: { - objectMetadataMapItem: flatObjectMetadata, - flatFieldMetadataMaps, - }, - }); - - return Object.keys(selectableFields).filter( - (columnName) => selectableFields[columnName], - ); -}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util.ts new file mode 100644 index 0000000000..239b9f5878 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util.ts @@ -0,0 +1,34 @@ +import { isDefined } from 'twenty-shared/utils'; + +// Recursively removes undefined values from an object +// This is needed because workflows/tools may pass partial composite fields +// with undefined sub-properties, but the validation layer expects either +// a value or null (not undefined) +export const removeUndefinedFromRecord = >( + record: T, +): T => { + const result: Record = {}; + + for (const [key, value] of Object.entries(record)) { + if (!isDefined(value)) { + continue; + } + + // Recursively clean nested objects (composite fields like LINKS, ADDRESS, etc.) + // but preserve arrays as-is (they should be handled separately if needed) + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + const cleaned = removeUndefinedFromRecord( + value as Record, + ); + + // Only include the nested object if it has at least one defined property + if (Object.keys(cleaned).length > 0) { + result[key] = cleaned; + } + } else { + result[key] = value; + } + } + + return result as T; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts index df3fc4ff2a..3d1bafb06b 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts @@ -7,6 +7,7 @@ import { } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; import { type ToolProvider, type ToolProviderContext, @@ -45,6 +46,23 @@ export class DatabaseToolProvider implements ToolProvider { async generateTools(context: ToolProviderContext): Promise { const tools: ToolSet = {}; + // Build authContext from available context info + // userWorkspaceId is required for user-based tool generation + if (!context.userWorkspaceId) { + return tools; + } + + const authContext: WorkspaceAuthContext = { + user: context.userId ? { id: context.userId } : null, + workspace: { + id: context.workspaceId, + } as WorkspaceAuthContext['workspace'], + workspaceMemberId: undefined, + userWorkspaceId: context.userWorkspaceId, + apiKey: null, + application: null, + } as WorkspaceAuthContext; + const { rolesPermissions } = await this.workspaceCacheService.getOrRecompute(context.workspaceId, [ 'rolesPermissions', @@ -108,6 +126,7 @@ export class DatabaseToolProvider implements ToolProvider { }, { workspaceId: context.workspaceId, + authContext, rolePermissionConfig: context.rolePermissionConfig, actorContext: context.actorContext, }, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index 505f0abdce..f4bf5bae9d 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -59,8 +59,15 @@ export class ToolRegistryService { async buildToolIndex( workspaceId: string, roleId: string, + options?: { userId?: string; userWorkspaceId?: string }, ): Promise { - const context = this.buildContext(workspaceId, roleId); + const context = this.buildContext( + workspaceId, + roleId, + undefined, + options?.userId, + options?.userWorkspaceId, + ); const entries: ToolIndexEntry[] = []; for (const provider of this.providers) { @@ -82,10 +89,16 @@ export class ToolRegistryService { query: string, workspaceId: string, roleId: string, - options: ToolSearchOptions = {}, + options: ToolSearchOptions & { + userId?: string; + userWorkspaceId?: string; + } = {}, ): Promise { - const { limit = 5, category } = options; - const index = await this.buildToolIndex(workspaceId, roleId); + const { limit = 5, category, userId, userWorkspaceId } = options; + const index = await this.buildToolIndex(workspaceId, roleId, { + userId, + userWorkspaceId, + }); const queryLower = query.toLowerCase(); const queryTerms = queryLower diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts index 65b1af2cdb..f910cfa59a 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.entity.ts @@ -16,7 +16,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 8b4bc2f10e..c61df40609 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -1,7 +1,7 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { IDField } from '@ptc-org/nestjs-query-graphql'; -import { Application } from 'cloudflare/resources/zero-trust/access/applications/applications'; +import { type Application } from 'cloudflare/resources/zero-trust/access/applications/applications'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { Check, @@ -14,7 +14,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 96575e62ce..661dc9e77d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -105,6 +105,7 @@ export class ChatExecutionService { const toolCatalog = await this.toolRegistry.buildToolIndex( workspace.id, roleId, + { userId, userWorkspaceId }, ); const skillCatalog = this.skillsService.getAllSkills(); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index 928e2322f2..303bfc925c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { FindOneOptions, Repository } from 'typeorm'; +import { type FindOneOptions, type Repository } from 'typeorm'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; @@ -17,7 +17,7 @@ import { import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { fromCreateFieldInputToFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util'; import { fromDeleteFieldInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util'; import { fromUpdateFieldInputToFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util'; diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts index 57eaf79919..8dd14a0670 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts @@ -7,7 +7,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, Unique, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index 09fd29031c..4130900961 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -5,7 +5,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, Unique, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts index 9fdd7159cd..f484503a26 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts @@ -7,7 +7,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, Unique, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.entity.ts index 81d4d6e175..c01db5f4bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.entity.ts @@ -6,7 +6,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, Unique, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity.ts index 7fb75e6fe4..d85806731a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity.ts @@ -10,7 +10,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity.ts index bf276c7881..c9f4190bb3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity.ts @@ -9,7 +9,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; @@ -17,8 +17,8 @@ import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/type import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity'; import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum'; -import { AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type'; -import { GridPosition } from 'src/engine/metadata-modules/page-layout-widget/types/grid-position.type'; +import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type'; +import { type GridPosition } from 'src/engine/metadata-modules/page-layout-widget/types/grid-position.type'; @Entity({ name: 'pageLayoutWidget', schema: 'core' }) @ObjectType('PageLayoutWidget') diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/entities/page-layout.entity.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/entities/page-layout.entity.ts index 74b565950e..5a6a330885 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/entities/page-layout.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/entities/page-layout.entity.ts @@ -10,7 +10,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts index 1eca4b1bb1..15d78846e0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts @@ -7,7 +7,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, Unique, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts index 1f4fc7663a..cb6e9b5999 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts @@ -4,7 +4,7 @@ import { Entity, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, Unique, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts index 27e8e4b690..41411023d9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts @@ -6,7 +6,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, Unique, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/entities/view-field.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/entities/view-field.entity.ts index 85ac8f09f0..287792c5d7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field/entities/view-field.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field/entities/view-field.entity.ts @@ -7,7 +7,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity.ts index 73e5bc009d..f83de3e81a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity.ts @@ -8,7 +8,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter/entities/view-filter.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter/entities/view-filter.entity.ts index 63ce62d1ad..1da3211a22 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter/entities/view-filter.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter/entities/view-filter.entity.ts @@ -8,14 +8,14 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity'; -import { ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type'; +import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; @Entity({ name: 'viewFilter', schema: 'core' }) diff --git a/packages/twenty-server/src/engine/metadata-modules/view-group/entities/view-group.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view-group/entities/view-group.entity.ts index 6bf16d2688..d43c487129 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-group/entities/view-group.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-group/entities/view-group.entity.ts @@ -7,7 +7,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-sort/entities/view-sort.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view-sort/entities/view-sort.entity.ts index b5c002cf83..701938d46b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-sort/entities/view-sort.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-sort/entities/view-sort.entity.ts @@ -7,7 +7,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts index 87d1b80edd..ee5c5b8540 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts @@ -9,7 +9,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, - Relation, + type Relation, UpdateDateColumn, } from 'typeorm'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface.ts index 90ad4f6afe..a10c7ca926 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface.ts @@ -1,4 +1,4 @@ -import { Column, Index, JoinColumn, ManyToOne, Relation } from 'typeorm'; +import { Column, Index, JoinColumn, ManyToOne, type Relation } from 'typeorm'; import type { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/workspace-sync/types/workspace-related-entity'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/services/workflow-execution-context.service.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/services/workflow-execution-context.service.ts index de47591cea..7f04643161 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/services/workflow-execution-context.service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/services/workflow-execution-context.service.ts @@ -7,7 +7,9 @@ import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/work import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role'; import { type WorkflowRunWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity'; import { type WorkflowExecutionContext } from 'src/modules/workflow/workflow-executor/types/workflow-execution-context.type'; import { WorkflowRunWorkspaceService as WorkflowRunService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; @@ -20,6 +22,7 @@ export class WorkflowExecutionContextService { private readonly userWorkspaceService: UserWorkspaceService, private readonly userRoleService: UserRoleService, private readonly applicationService: ApplicationService, + private readonly roleService: RoleService, ) {} async getExecutionContext(runInfo: { @@ -93,16 +96,31 @@ export class WorkflowExecutionContextService { workspaceId, ); - const rolePermissionConfig = isDefined( - application.defaultServerlessFunctionRoleId, - ) - ? { unionOf: [application.defaultServerlessFunctionRoleId] } + // Use the application's role if set, otherwise fall back to admin role + // In the future we should probably assign the Admin role to the Standard Application + let roleId = application.defaultServerlessFunctionRoleId; + + if (!isDefined(roleId)) { + // Fallback: Look up admin role for existing workspaces without defaultServerlessFunctionRoleId + const adminRole = await this.roleService.getRoleByUniversalIdentifier({ + universalIdentifier: ADMIN_ROLE.standardId, + workspaceId, + }); + + roleId = adminRole?.id ?? null; + } + + const rolePermissionConfig = isDefined(roleId) + ? { unionOf: [roleId] } : { shouldBypassPermissionChecks: true as const }; const authContext = { user: null, apiKey: null, - application, + application: { + ...application, + defaultServerlessFunctionRoleId: roleId, + }, workspace, workspaceMemberId: undefined, userWorkspaceId: undefined, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts index 0f44eab927..27c0974de5 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts @@ -6,6 +6,7 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; +import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module'; @@ -21,6 +22,7 @@ import { AiAgentWorkflowAction } from './ai-agent.workflow-action'; WorkflowRunModule, UserWorkspaceModule, UserRoleModule, + RoleModule, ], providers: [WorkflowExecutionContextService, AiAgentWorkflowAction], exports: [AiAgentWorkflowAction], diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts index a7327d535a..a5ca784071 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; +import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; @@ -20,6 +21,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow WorkflowRunModule, UserWorkspaceModule, UserRoleModule, + RoleModule, WorkflowCommonModule, ], providers: [ diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/workflow-database-event-trigger.listener.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/workflow-database-event-trigger.listener.ts index 5a324f0641..2560eae3ce 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/workflow-database-event-trigger.listener.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/workflow-database-event-trigger.listener.ts @@ -24,7 +24,7 @@ import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-module import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; -import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; +import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; import { AutomatedTriggerType, type WorkflowAutomatedTriggerWorkspaceEntity,