From 5adc9fe9b28aa63b2a62d1fc7148d5610aa6927d Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Fri, 10 Oct 2025 00:55:34 +0530 Subject: [PATCH] feat: mutualize CRUD tools between workflows and AI (#14996) Closes [#1662](https://github.com/twentyhq/core-team-issues/issues/1662) --- .../record-field/ui/types/FieldMetadata.ts | 1 + .../src/engine/core-modules/ai/ai.module.ts | 4 +- .../services/__tests__/tool.service.spec.ts | 88 +-- .../core-modules/ai/services/tool.service.ts | 463 +------------ .../utils/__tests__/components.utils.spec.ts | 5 +- .../exceptions/record-crud.exception.ts | 19 + .../record-crud/record-crud.module.ts | 32 + .../services/create-record.service.ts | 122 ++++ .../services/delete-record.service.ts | 137 ++++ .../services/find-records.service.ts | 185 +++++ .../services/update-record.service.ts | 160 +++++ .../types/create-record-params.type.ts | 8 + .../types/delete-record-params.type.ts | 7 + .../types/find-records-params.type.ts | 18 + .../types/find-records-result.type.ts | 4 + .../types/object-record-properties.type.ts | 2 + .../types/update-record-params.type.ts | 10 + .../tool/types/tool-output.type.ts | 4 +- .../composite-types/actor.composite-type.ts | 1 + ...ject-metadata-to-schema-properties.util.ts | 1 + .../create-record.workflow-action.ts | 88 +-- .../delete-record.workflow-action.ts | 82 +-- .../record-crud-action.exception.ts | 8 - .../find-records.workflow-action.ts | 146 +--- .../record-crud/record-crud-action.module.ts | 16 +- .../workflow-record-crud-action-input.type.ts | 7 +- .../update-record.workflow-action.ts | 109 +-- .../agent-tool.service.integration-spec.ts | 639 ------------------ .../agent/utils/agent-tool-test-utils.ts | 32 + 29 files changed, 899 insertions(+), 1499 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/exceptions/record-crud.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/delete-record.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/create-record-params.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/delete-record-params.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-params.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-result.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/object-record-properties.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/update-record-params.type.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts index 5489a306fd6..9a86da65522 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts @@ -283,6 +283,7 @@ const FieldActorSourceSchema = z.union([ z.literal('SYSTEM'), z.literal('WORKFLOW'), z.literal('WEBHOOK'), + z.literal('AGENT'), ]); export const FieldActorValueSchema = z.object({ diff --git a/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts b/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts index 6fb0638235b..093e01694f8 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts @@ -11,7 +11,7 @@ import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-ada import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; -import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; +import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module'; import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; @@ -29,7 +29,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module'; TypeOrmModule.forFeature([RoleEntity]), TokenModule, FeatureFlagModule, - RecordTransformerModule, + RecordCrudModule, ObjectMetadataModule, WorkspacePermissionsCacheModule, WorkspaceCacheStorageModule, diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/tool.service.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/tool.service.spec.ts index 677d6ef9004..1b47a571203 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/tool.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/tool.service.spec.ts @@ -1,6 +1,10 @@ import { Test } from '@nestjs/testing'; import { ToolService } from 'src/engine/core-modules/ai/services/tool.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 { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; @@ -24,10 +28,7 @@ describe('ToolService', () => { const roleId = 'role_1'; let service: ToolService; - let ormManager: TwentyORMGlobalManager; let permissionsCacheService: WorkspacePermissionsCacheService; - let transformer: RecordInputTransformerService; - let workspaceCache: WorkspaceCacheStorageService; const testObject = getMockObjectMetadataEntity({ workspaceId: '', @@ -102,14 +103,27 @@ describe('ToolService', () => { }), }, }, + { + provide: CreateRecordService, + useValue: { execute: jest.fn() }, + }, + { + provide: UpdateRecordService, + useValue: { execute: jest.fn() }, + }, + { + provide: DeleteRecordService, + useValue: { execute: jest.fn() }, + }, + { + provide: FindRecordsService, + useValue: { execute: jest.fn() }, + }, ], }).compile(); service = moduleRef.get(ToolService); - ormManager = moduleRef.get(TwentyORMGlobalManager); permissionsCacheService = moduleRef.get(WorkspacePermissionsCacheService); - transformer = moduleRef.get(RecordInputTransformerService); - workspaceCache = moduleRef.get(WorkspaceCacheStorageService); }); describe('listTools', () => { @@ -133,68 +147,6 @@ describe('ToolService', () => { }); }); - describe('createRecord', () => { - it('should create a record successfully', async () => { - const record = { id: 'r1', name: 'Test' }; - - mockRepo.save.mockResolvedValue(record); - - const result = await service.createRecord( - 'testObject', - { name: 'Test' }, - workspaceId, - roleId, - ); - - expect(result.success).toBe(true); - expect(result.result).toEqual(record); - expect(ormManager.getRepositoryForWorkspace).toHaveBeenCalledWith( - workspaceId, - 'testObject', - { roleId }, - ); - expect(workspaceCache.getObjectMetadataMapsOrThrow).toHaveBeenCalledWith( - workspaceId, - ); - expect(transformer.process).toHaveBeenCalled(); - expect(mockRepo.save).toHaveBeenCalledWith({ name: 'Test' }); - }); - }); - - describe('updateRecord', () => { - it('should return error when id is missing', async () => { - const result = await (service as any).updateRecord( - 'testObject', - { name: 'No ID' }, - workspaceId, - roleId, - ); - - expect(result.success).toBe(false); - expect(result.error).toBe('Record ID is required for update'); - }); - }); - - describe('findRecords', () => { - it('should return records and count', async () => { - const records = [{ id: 'a' }, { id: 'b' }]; - - mockRepo.find.mockResolvedValue(records); - - const result = await (service as any).findRecords( - 'testObject', - {}, - workspaceId, - roleId, - ); - - expect(result.success).toBe(true); - expect(result.result.records).toEqual(records); - expect(result.result.count).toBe(2); - expect(mockRepo.find).toHaveBeenCalled(); - }); - }); - describe('softDeleteManyRecords', () => { it('should error when filter is invalid', async () => { const result = await (service as any).softDeleteManyRecords( diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts b/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts index 150b77a40c9..bc4239a7953 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts @@ -2,8 +2,10 @@ import { Injectable } from '@nestjs/common'; import { type ToolSet } from 'ai'; -import { buildWhereConditions } from 'src/engine/core-modules/ai/utils/find-records-filters.utils'; -import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.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 { generateBulkDeleteToolSchema, generateFindOneToolSchema, @@ -13,10 +15,8 @@ import { } from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils'; import { isWorkflowRunObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-run-object.util'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; -import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @Injectable() export class ToolService { @@ -24,8 +24,10 @@ export class ToolService { private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly objectMetadataService: ObjectMetadataService, protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService, - private readonly recordInputTransformerService: RecordInputTransformerService, - private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + private readonly createRecordService: CreateRecordService, + private readonly updateRecordService: UpdateRecordService, + private readonly deleteRecordService: DeleteRecordService, + private readonly findRecordsService: FindRecordsService, ) {} async listTools(roleId: string, workspaceId: string): Promise { @@ -63,12 +65,12 @@ export class ToolService { description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`, inputSchema: getRecordInputSchema(objectMetadata), execute: async (parameters) => { - return this.createRecord( - objectMetadata.nameSingular, - parameters.input, + return this.createRecordService.execute({ + objectName: objectMetadata.nameSingular, + objectRecord: parameters.input, workspaceId, roleId, - ); + }); }, }; @@ -76,12 +78,15 @@ export class ToolService { description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`, inputSchema: getRecordInputSchema(objectMetadata), execute: async (parameters) => { - return this.updateRecord( - objectMetadata.nameSingular, - parameters.input, + const { id, ...objectRecord } = parameters.input; + + return this.updateRecordService.execute({ + objectName: objectMetadata.nameSingular, + objectRecordId: id, + objectRecord, workspaceId, roleId, - ); + }); }, }; } @@ -91,12 +96,16 @@ export class ToolService { description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination. Returns an array of matching records with their full data.`, inputSchema: generateFindToolSchema(objectMetadata), execute: async (parameters) => { - return this.findRecords( - objectMetadata.nameSingular, - parameters.input, + const { limit, offset, ...filter } = parameters.input; + + return this.findRecordsService.execute({ + objectName: objectMetadata.nameSingular, + filter, + limit, + offset, workspaceId, roleId, - ); + }); }, }; @@ -104,12 +113,13 @@ export class ToolService { description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`, inputSchema: generateFindOneToolSchema(), execute: async (parameters) => { - return this.findOneRecord( - objectMetadata.nameSingular, - parameters.input, + return this.findRecordsService.execute({ + objectName: objectMetadata.nameSingular, + filter: { id: { eq: parameters.input.id } }, + limit: 1, workspaceId, roleId, - ); + }); }, }; } @@ -119,12 +129,13 @@ export class ToolService { description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`, inputSchema: generateSoftDeleteToolSchema(), execute: async (parameters) => { - return this.softDeleteRecord( - objectMetadata.nameSingular, - parameters.input, + return this.deleteRecordService.execute({ + objectName: objectMetadata.nameSingular, + objectRecordId: parameters.input.id, workspaceId, roleId, - ); + soft: true, + }); }, }; @@ -146,340 +157,6 @@ export class ToolService { return tools; } - private async findRecords( - objectName: string, - parameters: Record, - workspaceId: string, - roleId: string, - ) { - try { - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - objectName, - { roleId }, - ); - - const { limit = 100, offset = 0, ...searchCriteria } = parameters; - - const whereConditions = buildWhereConditions(searchCriteria); - - const records = await repository.find({ - where: whereConditions, - take: limit as number, - skip: offset as number, - order: { createdAt: 'DESC' }, - }); - - return { - success: true, - message: `Found ${records.length} ${objectName} records`, - result: { - records, - count: records.length, - }, - }; - } catch (error) { - return { - success: false, - message: `Failed to find ${objectName} records`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - - private async findOneRecord( - objectName: string, - parameters: Record, - workspaceId: string, - roleId: string, - ) { - try { - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - objectName, - { roleId }, - ); - - const { id } = parameters; - - if (!id || typeof id !== 'string') { - return { - success: false, - message: `Failed to find ${objectName}: Record ID is required`, - error: 'Record ID is required', - }; - } - - const record = await repository.findOne({ - where: { id }, - }); - - if (!record) { - return { - success: false, - message: `Failed to find ${objectName}: Record with ID ${id} not found`, - error: 'Record not found', - }; - } - - return { - success: true, - message: `Found ${objectName} record`, - result: record, - }; - } catch (error) { - return { - success: false, - message: `Failed to find ${objectName} record`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - - async createRecord( - objectName: string, - parameters: Record, - workspaceId: string, - roleId: string, - ) { - try { - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - objectName, - { roleId }, - ); - - const objectMetadataMaps = - await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow( - workspaceId, - ); - - const objectMetadataItemWithFieldsMaps = - getObjectMetadataMapItemByNameSingular(objectMetadataMaps, objectName); - - if (!objectMetadataItemWithFieldsMaps) { - return { - success: false, - message: `Failed to create ${objectName}: Object metadata not found`, - error: 'Object metadata not found', - }; - } - - const transformedCreateData = - await this.recordInputTransformerService.process({ - recordInput: parameters, - objectMetadataMapItem: objectMetadataItemWithFieldsMaps, - }); - - const createdRecord = await repository.save(transformedCreateData); - - return { - success: true, - message: `Successfully created ${objectName}`, - result: createdRecord, - }; - } catch (error) { - return { - success: false, - message: `Failed to create ${objectName}`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - - private async updateRecord( - objectName: string, - parameters: Record, - workspaceId: string, - roleId: string, - ) { - try { - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - objectName, - { roleId }, - ); - - const { id, ...updateData } = parameters; - - if (!id || typeof id !== 'string') { - return { - success: false, - message: `Failed to update ${objectName}: Record ID is required`, - error: 'Record ID is required for update', - }; - } - - const existingRecord = await repository.findOne({ - where: { id }, - }); - - if (!existingRecord) { - return { - success: false, - message: `Failed to update ${objectName}: Record with ID ${id} not found`, - error: 'Record not found', - }; - } - - const objectMetadataMaps = - await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow( - workspaceId, - ); - - const objectMetadataItemWithFieldsMaps = - getObjectMetadataMapItemByNameSingular(objectMetadataMaps, objectName); - - if (!objectMetadataItemWithFieldsMaps) { - return { - success: false, - message: `Failed to update ${objectName}: Object metadata not found`, - error: 'Object metadata not found', - }; - } - - const transformedUpdateData = - await this.recordInputTransformerService.process({ - recordInput: updateData, - objectMetadataMapItem: objectMetadataItemWithFieldsMaps, - }); - - await repository.update(id as string, transformedUpdateData); - - const updatedRecord = await repository.findOne({ - where: { id: id as string }, - }); - - if (!updatedRecord) { - return { - success: false, - message: `Failed to update ${objectName}: Could not retrieve updated record`, - error: 'Failed to retrieve updated record', - }; - } - - return { - success: true, - message: `Successfully updated ${objectName}`, - result: updatedRecord, - }; - } catch (error) { - return { - success: false, - message: `Failed to update ${objectName}`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - - private async softDeleteRecord( - objectName: string, - parameters: Record, - workspaceId: string, - roleId: string, - ) { - try { - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - objectName, - { roleId }, - ); - - const { id } = parameters; - - if (!id || typeof id !== 'string') { - return { - success: false, - message: `Failed to soft delete ${objectName}: Record ID is required`, - error: 'Record ID is required for soft delete', - }; - } - - const existingRecord = await repository.findOne({ - where: { id }, - }); - - if (!existingRecord) { - return { - success: false, - message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`, - error: 'Record not found', - }; - } - - await repository.softDelete(id); - - return { - success: true, - message: `Successfully soft deleted ${objectName}`, - result: { id }, - }; - } catch (error) { - return { - success: false, - message: `Failed to soft delete ${objectName}`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - - private async _destroyRecord( - objectName: string, - parameters: Record, - workspaceId: string, - roleId: string, - ) { - try { - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - objectName, - { roleId }, - ); - - const { id } = parameters; - - if (!id || typeof id !== 'string') { - return { - success: false, - message: `Failed to destroy ${objectName}: Record ID is required`, - error: 'Record ID is required for destroy', - }; - } - - const existingRecord = await repository.findOne({ - where: { id }, - }); - - if (!existingRecord) { - return { - success: false, - message: `Failed to destroy ${objectName}: Record with ID ${id} not found`, - error: 'Record not found', - }; - } - - await repository.remove(existingRecord); - - return { - success: true, - message: `Successfully destroyed ${objectName}`, - result: { id }, - }; - } catch (error) { - return { - success: false, - message: `Failed to destroy ${objectName}`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - private async softDeleteManyRecords( objectName: string, parameters: Record, @@ -545,70 +222,4 @@ export class ToolService { }; } } - - private async _destroyManyRecords( - objectName: string, - parameters: Record, - workspaceId: string, - roleId: string, - ) { - try { - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - objectName, - { roleId }, - ); - - const { filter } = parameters; - - if (!filter || typeof filter !== 'object' || !('id' in filter)) { - return { - success: false, - message: `Failed to destroy many ${objectName}: Filter with record IDs is required`, - error: 'Filter with record IDs is required for bulk destroy', - }; - } - - const idFilter = filter.id as Record; - const recordIds = idFilter.in as string[]; - - if (!Array.isArray(recordIds) || recordIds.length === 0) { - return { - success: false, - message: `Failed to destroy many ${objectName}: At least one record ID is required`, - error: 'At least one record ID is required for bulk destroy', - }; - } - - const existingRecords = await repository.find({ - where: { id: { in: recordIds } }, - }); - - if (existingRecords.length === 0) { - return { - success: false, - message: `Failed to destroy many ${objectName}: No records found with the provided IDs`, - error: 'No records found to destroy', - }; - } - - await repository.delete({ id: { in: recordIds } }); - - return { - success: true, - message: `Successfully destroyed ${existingRecords.length} ${objectName} records`, - result: { - count: existingRecords.length, - destroyedIds: recordIds, - }, - }; - } catch (error) { - return { - success: false, - message: `Failed to destroy many ${objectName}`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } } diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts index 4034667b7c3..41769b209c9 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts @@ -1,6 +1,6 @@ +import { faker } from '@faker-js/faker'; import { type EachTestingContext } from 'twenty-shared/testing'; import { FieldMetadataType } from 'twenty-shared/types'; -import { faker } from '@faker-js/faker'; import { NumberDataType } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; @@ -61,6 +61,7 @@ describe('computeSchemaComponents', () => { "EMAIL", "CALENDAR", "WORKFLOW", + "AGENT", "API", "IMPORT", "MANUAL", @@ -292,6 +293,7 @@ describe('computeSchemaComponents', () => { "EMAIL", "CALENDAR", "WORKFLOW", + "AGENT", "API", "IMPORT", "MANUAL", @@ -562,6 +564,7 @@ describe('computeSchemaComponents', () => { "EMAIL", "CALENDAR", "WORKFLOW", + "AGENT", "API", "IMPORT", "MANUAL", diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/exceptions/record-crud.exception.ts b/packages/twenty-server/src/engine/core-modules/record-crud/exceptions/record-crud.exception.ts new file mode 100644 index 00000000000..7acf18bd8e9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/exceptions/record-crud.exception.ts @@ -0,0 +1,19 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class RecordCrudException extends CustomException { + code: RecordCrudExceptionCode; + constructor(message: string, code: RecordCrudExceptionCode) { + super(message, code); + } +} + +export enum RecordCrudExceptionCode { + INVALID_REQUEST = 'INVALID_REQUEST', + WORKSPACE_ID_NOT_FOUND = 'WORKSPACE_ID_NOT_FOUND', + OBJECT_NOT_FOUND = 'OBJECT_NOT_FOUND', + RECORD_NOT_FOUND = 'RECORD_NOT_FOUND', + RECORD_CREATION_FAILED = 'RECORD_CREATION_FAILED', + RECORD_UPDATE_FAILED = 'RECORD_UPDATE_FAILED', + RECORD_DELETION_FAILED = 'RECORD_DELETION_FAILED', + QUERY_FAILED = 'QUERY_FAILED', +} 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 new file mode 100644 index 00000000000..039a45b618a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; + +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 { 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'; + +@Module({ + imports: [ + TwentyORMModule, + RecordPositionModule, + RecordTransformerModule, + WorkflowCommonModule, + ], + providers: [ + CreateRecordService, + UpdateRecordService, + DeleteRecordService, + FindRecordsService, + ], + exports: [ + CreateRecordService, + UpdateRecordService, + DeleteRecordService, + FindRecordsService, + ], +}) +export class RecordCrudModule {} 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 new file mode 100644 index 00000000000..2b35c6b60c9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { isDefined } from 'class-validator'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; + +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { type CreateRecordParams } from 'src/engine/core-modules/record-crud/types/create-record-params.type'; +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 { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class CreateRecordService { + private readonly logger = new Logger(CreateRecordService.name); + + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly recordPositionService: RecordPositionService, + private readonly recordInputTransformerService: RecordInputTransformerService, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + ) {} + + async execute(params: CreateRecordParams): Promise { + const { objectName, objectRecord, workspaceId, roleId } = params; + + if (!workspaceId) { + return { + success: false, + message: 'Failed to create record: Workspace ID is required', + error: 'Workspace ID not found', + }; + } + + try { + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + objectName, + roleId ? { roleId } : { shouldBypassPermissionChecks: true }, + ); + + const { objectMetadataItemWithFieldsMaps } = + await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( + objectName, + workspaceId, + ); + + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, + isSystem: objectMetadataItemWithFieldsMaps.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: objectMetadataItemWithFieldsMaps, + workspaceId, + }); + + const validObjectRecord = Object.fromEntries( + Object.entries(objectRecord).filter(([key]) => + isDefined(objectMetadataItemWithFieldsMaps.fieldIdByName[key]), + ), + ); + + const transformedObjectRecord = + await this.recordInputTransformerService.process({ + recordInput: validObjectRecord, + objectMetadataMapItem: objectMetadataItemWithFieldsMaps, + }); + + const insertResult = await repository.insert({ + ...transformedObjectRecord, + position, + createdBy: { + source: roleId ? FieldActorSource.AGENT : FieldActorSource.WORKFLOW, + name: roleId ? 'Agent' : 'Workflow', + }, + }); + + const [createdRecord] = insertResult.generatedMaps; + + this.logger.log(`Record created successfully in ${objectName}`); + + return { + success: true, + message: `Record created successfully in ${objectName}`, + result: createdRecord, + }; + } catch (error) { + if (error instanceof RecordCrudException) { + return { + success: false, + message: `Failed to create record in ${objectName}`, + error: error.message, + }; + } + + this.logger.error(`Failed to create record: ${error}`); + + return { + success: false, + message: `Failed to create record in ${objectName}`, + error: + error instanceof Error ? error.message : 'Failed to create record', + }; + } + } +} 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 new file mode 100644 index 00000000000..ad2e10f8971 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/delete-record.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { isDefined, isValidUuid } from 'twenty-shared/utils'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; + +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class DeleteRecordService { + private readonly logger = new Logger(DeleteRecordService.name); + + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + ) {} + + async execute(params: DeleteRecordParams): Promise { + const { + objectName, + objectRecordId, + workspaceId, + roleId, + soft = true, + } = params; + + if (!workspaceId) { + return { + success: false, + message: 'Failed to delete record: Workspace ID is required', + error: 'Workspace ID not found', + }; + } + + if (!isDefined(objectRecordId) || !isValidUuid(objectRecordId)) { + return { + success: false, + message: 'Failed to delete: Object record ID must be a valid UUID', + error: 'Invalid object record ID', + }; + } + + try { + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + objectName, + roleId ? { roleId } : { shouldBypassPermissionChecks: true }, + ); + + const { objectMetadataItemWithFieldsMaps } = + await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( + objectName, + workspaceId, + ); + + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, + isSystem: objectMetadataItemWithFieldsMaps.isSystem, + }) + ) { + throw new RecordCrudException( + 'Failed to delete: Object cannot be deleted by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + 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 }, + }; + } + } catch (error) { + if (error instanceof RecordCrudException) { + return { + success: false, + message: `Failed to delete record from ${objectName}`, + error: error.message, + }; + } + + this.logger.error(`Failed to delete record: ${error}`); + + return { + success: false, + message: `Failed to delete record from ${objectName}`, + error: + error instanceof Error ? error.message : 'Failed to delete record', + }; + } + } +} 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 new file mode 100644 index 00000000000..f896703eaf8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts @@ -0,0 +1,185 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; +import { type ObjectLiteral } from 'typeorm'; + +import { + type ObjectRecordFilter, + type ObjectRecordOrderBy, + OrderByDirection, +} 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 { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type'; +import { FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class FindRecordsService { + private readonly logger = new Logger(FindRecordsService.name); + + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + ) {} + + async execute( + params: FindRecordsParams, + ): Promise> { + const { + objectName, + filter, + orderBy, + limit, + offset = 0, + workspaceId, + roleId, + } = params; + + if (!workspaceId) { + return { + success: false, + message: 'Failed to find records: Workspace ID is required', + error: 'Workspace ID not found', + }; + } + + try { + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + objectName, + roleId ? { roleId } : { shouldBypassPermissionChecks: true }, + ); + + const { objectMetadataItemWithFieldsMaps, objectMetadataMaps } = + await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( + objectName, + workspaceId, + ); + + const graphqlQueryParser = new GraphqlQueryParser( + objectMetadataItemWithFieldsMaps, + objectMetadataMaps, + ); + + const records = await this.getObjectRecords({ + objectName, + filter, + orderBy, + limit, + offset, + repository, + graphqlQueryParser, + }); + + const totalCount = await this.getTotalCount({ + objectName, + filter, + repository, + graphqlQueryParser, + }); + + this.logger.log(`Found ${records.length} records in ${objectName}`); + + return { + success: true, + message: `Found ${records.length} ${objectName} records`, + result: { + records, + count: totalCount, + }, + }; + } catch (error) { + this.logger.error(`Failed to find records: ${error}`); + + return { + success: false, + message: `Failed to find ${objectName} records`, + error: + error instanceof Error ? error.message : 'Failed to find records', + }; + } + } + + private async getObjectRecords({ + objectName, + filter, + orderBy, + limit, + offset, + repository, + graphqlQueryParser, + }: { + objectName: string; + filter: + | Partial + | Partial[] + | undefined; + orderBy: Partial | undefined; + limit: number | undefined; + offset: number; + repository: WorkspaceRepository; + graphqlQueryParser: GraphqlQueryParser; + }): 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, + false, + ); + + return withOrderByQueryBuilder + .skip(offset) + .take(limit ? Math.min(limit, QUERY_MAX_RECORDS) : QUERY_MAX_RECORDS) + .getMany(); + } + + private async getTotalCount({ + objectName, + filter, + repository, + graphqlQueryParser, + }: { + objectName: string; + filter: + | Partial + | Partial[] + | undefined; + repository: WorkspaceRepository; + graphqlQueryParser: GraphqlQueryParser; + }): Promise { + const countQueryBuilder = repository.createQueryBuilder(objectName); + + const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder( + countQueryBuilder, + objectName, + filter ?? {}, + ); + + const withDeletedCountQueryBuilder = + graphqlQueryParser.applyDeletedAtToBuilder( + withFilterCountQueryBuilder, + filter ?? {}, + ); + + return withDeletedCountQueryBuilder.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 new file mode 100644 index 00000000000..154a68afd30 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts @@ -0,0 +1,160 @@ +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 { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { type UpdateRecordParams } from 'src/engine/core-modules/record-crud/types/update-record-params.type'; +import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class UpdateRecordService { + private readonly logger = new Logger(UpdateRecordService.name); + + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly recordInputTransformerService: RecordInputTransformerService, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + ) {} + + async execute(params: UpdateRecordParams): Promise { + const { + objectName, + objectRecordId, + objectRecord, + fieldsToUpdate, + workspaceId, + roleId, + } = params; + + if (!workspaceId) { + return { + success: false, + message: 'Failed to update record: Workspace ID is required', + error: 'Workspace ID not found', + }; + } + + if (!isDefined(objectRecordId) || !isValidUuid(objectRecordId)) { + return { + success: false, + message: 'Failed to update: Object record ID must be a valid UUID', + error: 'Invalid object record ID', + }; + } + + try { + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + objectName, + roleId ? { roleId } : { shouldBypassPermissionChecks: true }, + ); + + const previousObjectRecord = await repository.findOne({ + where: { + id: objectRecordId, + }, + }); + + 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, + }; + } + + const { objectMetadataItemWithFieldsMaps } = + await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( + objectName, + workspaceId, + ); + + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, + isSystem: objectMetadataItemWithFieldsMaps.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, + objectMetadataMapItem: objectMetadataItemWithFieldsMaps, + }); + + const updatedObjectRecord = { + ...previousObjectRecord, + ...objectRecordWithFilteredFields, + }; + + if (!deepEqual(updatedObjectRecord, previousObjectRecord)) { + await repository.update(objectRecordId, { + ...transformedObjectRecord, + }); + } + + this.logger.log(`Record updated successfully in ${objectName}`); + + return { + success: true, + message: `Record updated successfully in ${objectName}`, + result: updatedObjectRecord, + }; + } catch (error) { + if (error instanceof RecordCrudException) { + return { + success: false, + message: `Failed to update record in ${objectName}`, + error: error.message, + }; + } + + this.logger.error(`Failed to update record: ${error}`); + + return { + success: false, + message: `Failed to update record in ${objectName}`, + error: + error instanceof Error ? error.message : 'Failed to update record', + }; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/create-record-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/create-record-params.type.ts new file mode 100644 index 00000000000..2d83228991a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/create-record-params.type.ts @@ -0,0 +1,8 @@ +import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type'; + +export type CreateRecordParams = { + objectName: string; + objectRecord: ObjectRecordProperties; + workspaceId: string; + roleId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/delete-record-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/delete-record-params.type.ts new file mode 100644 index 00000000000..c546d831b34 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/delete-record-params.type.ts @@ -0,0 +1,7 @@ +export type DeleteRecordParams = { + objectName: string; + objectRecordId: string; + workspaceId: string; + roleId?: string; + soft?: boolean; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-params.type.ts new file mode 100644 index 00000000000..5381645bbfd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-params.type.ts @@ -0,0 +1,18 @@ +import { + type ObjectRecordFilter, + type ObjectRecordOrderBy, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +export type FindRecordsParams = { + objectName: string; + filter?: + | Record + | Record[] + | Partial + | Partial[]; + orderBy?: Partial; + limit?: number; + offset?: number; + workspaceId: string; + roleId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-result.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-result.type.ts new file mode 100644 index 00000000000..054b91f1dc2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-result.type.ts @@ -0,0 +1,4 @@ +export type FindRecordsResult = { + records: unknown[]; + count: number; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/object-record-properties.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/object-record-properties.type.ts new file mode 100644 index 00000000000..518f5003bb5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/object-record-properties.type.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ObjectRecordProperties = Record; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/update-record-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/update-record-params.type.ts new file mode 100644 index 00000000000..e90700ecdb8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/update-record-params.type.ts @@ -0,0 +1,10 @@ +import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type'; + +export type UpdateRecordParams = { + objectName: string; + objectRecordId: string; + objectRecord: ObjectRecordProperties; + fieldsToUpdate?: string[]; + workspaceId: string; + roleId?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts b/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts index e4a9c9557fb..7050c169aed 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts @@ -1,6 +1,6 @@ -export type ToolOutput = { +export type ToolOutput = { success: boolean; message: string; error?: string; - result?: unknown; + result?: T; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type.ts index 81c28b5c3fa..9f1882969bc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type.ts @@ -12,6 +12,7 @@ export enum FieldActorSource { EMAIL = 'EMAIL', CALENDAR = 'CALENDAR', WORKFLOW = 'WORKFLOW', + AGENT = 'AGENT', API = 'API', IMPORT = 'IMPORT', MANUAL = 'MANUAL', diff --git a/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts b/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts index c88de8940bd..1d5af38382a 100644 --- a/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts +++ b/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts @@ -256,6 +256,7 @@ export const convertObjectMetadataToSchemaProperties = ({ 'EMAIL', 'CALENDAR', 'WORKFLOW', + 'AGENT', 'API', 'IMPORT', 'MANUAL', diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts index ed2e5c018fa..cb0792ff45b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts @@ -1,34 +1,25 @@ import { Injectable } from '@nestjs/common'; -import { isDefined } from 'class-validator'; import { resolveInput } from 'twenty-shared/utils'; -import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; -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 { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; -import { - RecordCRUDActionException, - RecordCRUDActionExceptionCode, -} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception'; import { type WorkflowCreateRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; @Injectable() export class CreateRecordWorkflowAction implements WorkflowAction { constructor( - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly createRecordService: CreateRecordService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, - private readonly recordPositionService: RecordPositionService, - private readonly recordInputTransformerService: RecordInputTransformerService, - private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, ) {} async execute({ @@ -41,12 +32,12 @@ export class CreateRecordWorkflowAction implements WorkflowAction { stepId: currentStepId, }); - const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; + const { workspaceId } = this.scopedWorkspaceContextFactory.create(); if (!workspaceId) { - throw new RecordCRUDActionException( + throw new RecordCrudException( 'Failed to create: Workspace ID is required', - RecordCRUDActionExceptionCode.INVALID_REQUEST, + RecordCrudExceptionCode.INVALID_REQUEST, ); } @@ -55,62 +46,21 @@ export class CreateRecordWorkflowAction implements WorkflowAction { context, ) as WorkflowCreateRecordActionInput; - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - workflowActionInput.objectName, - { shouldBypassPermissionChecks: true }, - ); - - const { objectMetadataItemWithFieldsMaps } = - await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( - workflowActionInput.objectName, - workspaceId, - ); - - if ( - !canObjectBeManagedByWorkflow({ - nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, - isSystem: objectMetadataItemWithFieldsMaps.isSystem, - }) - ) { - throw new RecordCRUDActionException( - 'Failed to create: Object cannot be created by workflow', - RecordCRUDActionExceptionCode.INVALID_REQUEST, - ); - } - - const position = await this.recordPositionService.buildRecordPosition({ - value: 'first', - objectMetadata: objectMetadataItemWithFieldsMaps, + const toolOutput = await this.createRecordService.execute({ + objectName: workflowActionInput.objectName, + objectRecord: workflowActionInput.objectRecord, workspaceId, }); - const validObjectRecord = Object.fromEntries( - Object.entries(workflowActionInput.objectRecord).filter(([key]) => - isDefined(objectMetadataItemWithFieldsMaps.fieldIdByName[key]), - ), - ); - - const transformedObjectRecord = - await this.recordInputTransformerService.process({ - recordInput: validObjectRecord, - objectMetadataMapItem: objectMetadataItemWithFieldsMaps, - }); - - const insertResult = await repository.insert({ - ...transformedObjectRecord, - position, - createdBy: { - source: FieldActorSource.WORKFLOW, - name: 'Workflow', - }, - }); - - const [createdRecord] = insertResult.generatedMaps; + if (!toolOutput.success) { + throw new RecordCrudException( + toolOutput.error || toolOutput.message, + RecordCrudExceptionCode.RECORD_CREATION_FAILED, + ); + } return { - result: createdRecord, + result: toolOutput.result, }; } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts index 9fd6355b6e5..a37cb06b011 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts @@ -1,14 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { isDefined } from 'class-validator'; -import { isValidUuid, resolveInput } from 'twenty-shared/utils'; -import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; +import { isDefined, isValidUuid, resolveInput } from 'twenty-shared/utils'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, @@ -16,18 +17,13 @@ import { import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; -import { - RecordCRUDActionException, - RecordCRUDActionExceptionCode, -} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception'; import { isWorkflowDeleteRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-delete-record-action.guard'; import { type WorkflowDeleteRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; @Injectable() export class DeleteRecordWorkflowAction implements WorkflowAction { constructor( - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + private readonly deleteRecordService: DeleteRecordService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, ) {} @@ -58,69 +54,37 @@ export class DeleteRecordWorkflowAction implements WorkflowAction { !isValidUuid(workflowActionInput.objectRecordId) || !isDefined(workflowActionInput.objectName) ) { - throw new RecordCRUDActionException( - 'Failed to update: Object record ID and name are required', - RecordCRUDActionExceptionCode.INVALID_REQUEST, + throw new RecordCrudException( + 'Failed to delete: Object record ID and name are required', + RecordCrudExceptionCode.INVALID_REQUEST, ); } - const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; + const { workspaceId } = this.scopedWorkspaceContextFactory.create(); if (!workspaceId) { - throw new RecordCRUDActionException( + throw new RecordCrudException( 'Failed to delete: Workspace ID is required', - RecordCRUDActionExceptionCode.INVALID_REQUEST, + RecordCrudExceptionCode.INVALID_REQUEST, ); } - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - workflowActionInput.objectName, - { shouldBypassPermissionChecks: true }, - ); - - const { objectMetadataItemWithFieldsMaps } = - await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( - workflowActionInput.objectName, - workspaceId, - ); - - if ( - !canObjectBeManagedByWorkflow({ - nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, - isSystem: objectMetadataItemWithFieldsMaps.isSystem, - }) - ) { - throw new RecordCRUDActionException( - 'Failed to delete: Object cannot be deleted by workflow', - RecordCRUDActionExceptionCode.INVALID_REQUEST, - ); - } - - const objectRecord = await repository.findOne({ - where: { - id: workflowActionInput.objectRecordId, - }, + const toolOutput = await this.deleteRecordService.execute({ + objectName: workflowActionInput.objectName, + objectRecordId: workflowActionInput.objectRecordId, + workspaceId, + soft: true, }); - if (!objectRecord) { - throw new RecordCRUDActionException( - `Failed to delete: Record ${workflowActionInput.objectName} with id ${workflowActionInput.objectRecordId} not found`, - RecordCRUDActionExceptionCode.RECORD_NOT_FOUND, + if (!toolOutput.success) { + throw new RecordCrudException( + toolOutput.error || toolOutput.message, + RecordCrudExceptionCode.RECORD_DELETION_FAILED, ); } - const columnsToReturnForSoftDelete: string[] = []; - - await repository.softDelete( - workflowActionInput.objectRecordId, - undefined, - columnsToReturnForSoftDelete, - ); - return { - result: objectRecord, + result: toolOutput.result, }; } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception.ts deleted file mode 100644 index 4fecb069870..00000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { CustomException } from 'src/utils/custom-exception'; - -export class RecordCRUDActionException extends CustomException {} - -export enum RecordCRUDActionExceptionCode { - INVALID_REQUEST = 'INVALID_REQUEST', - RECORD_NOT_FOUND = 'RECORD_NOT_FOUND', -} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts index 648555fba44..7801990ed30 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts @@ -1,24 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { type Entity } from '@microsoft/microsoft-graph-types'; -import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; import { resolveInput } from 'twenty-shared/utils'; -import { type ObjectLiteral } from 'typeorm'; -import { - type ObjectRecordFilter, - type ObjectRecordOrderBy, - OrderByDirection, -} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; -import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; -import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, @@ -26,19 +17,14 @@ import { import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; -import { - RecordCRUDActionException, - RecordCRUDActionExceptionCode, -} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception'; import { isWorkflowFindRecordsAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-find-records-action.guard'; import { type WorkflowFindRecordsActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; @Injectable() export class FindRecordsWorkflowAction implements WorkflowAction { constructor( - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly findRecordsService: FindRecordsService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, - private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, ) {} async execute({ @@ -63,119 +49,39 @@ export class FindRecordsWorkflowAction implements WorkflowAction { context, ) as WorkflowFindRecordsActionInput; - const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; + const { workspaceId } = this.scopedWorkspaceContextFactory.create(); if (!workspaceId) { - throw new RecordCRUDActionException( + throw new RecordCrudException( 'Failed to read: Workspace ID is required', - RecordCRUDActionExceptionCode.INVALID_REQUEST, + RecordCrudExceptionCode.INVALID_REQUEST, ); } - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - workflowActionInput.objectName, - { shouldBypassPermissionChecks: true }, + const toolOutput = await this.findRecordsService.execute({ + objectName: workflowActionInput.objectName, + filter: workflowActionInput.filter?.gqlOperationFilter, + orderBy: workflowActionInput.orderBy, + limit: workflowActionInput.limit, + workspaceId, + }); + + if (!toolOutput.success) { + throw new RecordCrudException( + toolOutput.error || toolOutput.message, + RecordCrudExceptionCode.QUERY_FAILED, ); + } - const { objectMetadataItemWithFieldsMaps, objectMetadataMaps } = - await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( - workflowActionInput.objectName, - workspaceId, - ); - - const graphqlQueryParser = new GraphqlQueryParser( - objectMetadataItemWithFieldsMaps, - objectMetadataMaps, - ); - - const objectRecords = await this.getObjectRecords( - workflowActionInput, - objectMetadataItemWithFieldsMaps, - objectMetadataMaps, - repository, - graphqlQueryParser, - ); - - const totalCount = await this.getTotalCount( - workflowActionInput, - repository, - graphqlQueryParser, - ); + const records = toolOutput.result?.records ?? []; + const totalCount = toolOutput.result?.count ?? 0; return { result: { - first: objectRecords[0], - all: objectRecords, + first: records[0], + all: records, totalCount, }, }; } - - private async getObjectRecords( - workflowActionInput: WorkflowFindRecordsActionInput, - _objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps, - _objectMetadataMaps: ObjectMetadataMaps, - repository: WorkspaceRepository, - graphqlQueryParser: GraphqlQueryParser, - ) { - const queryBuilder = repository.createQueryBuilder( - workflowActionInput.objectName, - ); - - const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( - queryBuilder, - workflowActionInput.objectName, - workflowActionInput.filter?.gqlOperationFilter ?? - ({} as ObjectRecordFilter), - ); - - const orderByWithIdCondition = [ - ...(workflowActionInput.orderBy ?? []), - { id: OrderByDirection.AscNullsFirst }, - ] as ObjectRecordOrderBy; - - const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder( - withFilterQueryBuilder, - orderByWithIdCondition, - workflowActionInput.objectName, - false, - ); - - return withOrderByQueryBuilder - .take( - workflowActionInput.limit - ? Math.min(workflowActionInput.limit, QUERY_MAX_RECORDS) - : QUERY_MAX_RECORDS, - ) - .getMany(); - } - - private async getTotalCount( - workflowActionInput: WorkflowFindRecordsActionInput, - repository: WorkspaceRepository, - graphqlQueryParser: GraphqlQueryParser, - ) { - const countQueryBuilder = repository.createQueryBuilder( - workflowActionInput.objectName, - ); - - const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder( - countQueryBuilder, - workflowActionInput.objectName, - workflowActionInput.filter?.gqlOperationFilter ?? - ({} as ObjectRecordFilter), - ); - - const withDeletedCountQueryBuilder = - graphqlQueryParser.applyDeletedAtToBuilder( - withFilterCountQueryBuilder, - workflowActionInput.filter?.gqlOperationFilter - ? workflowActionInput.filter.gqlOperationFilter - : ({} as ObjectRecordFilter), - ); - - return withDeletedCountQueryBuilder.getCount(); - } } 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 f4be17cf889..7021dade209 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 @@ -1,26 +1,14 @@ import { Module } from '@nestjs/common'; -import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; - -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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; -import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action'; import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action'; import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action'; import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action'; @Module({ - imports: [ - WorkspaceCacheStorageModule, - NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]), - RecordPositionModule, - RecordTransformerModule, - WorkflowCommonModule, - ], + imports: [RecordCrudModule], providers: [ ScopedWorkspaceContextFactory, CreateRecordWorkflowAction, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts index b921ef7ec1f..7900d423546 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts @@ -3,17 +3,16 @@ import { type ObjectRecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ObjectRecord = Record; +import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type'; export type WorkflowCreateRecordActionInput = { objectName: string; - objectRecord: ObjectRecord; + objectRecord: ObjectRecordProperties; }; export type WorkflowUpdateRecordActionInput = { objectName: string; - objectRecord: ObjectRecord; + objectRecord: ObjectRecordProperties; objectRecordId: string; fieldsToUpdate: string[]; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts index fdd2b0d7b89..f38c8cbace3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts @@ -1,15 +1,15 @@ import { Injectable } from '@nestjs/common'; -import deepEqual from 'deep-equal'; import { isDefined, isValidUuid, resolveInput } from 'twenty-shared/utils'; -import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; -import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, @@ -17,20 +17,14 @@ import { import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; -import { - RecordCRUDActionException, - RecordCRUDActionExceptionCode, -} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/exceptions/record-crud-action.exception'; import { isWorkflowUpdateRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-update-record-action.guard'; import { type WorkflowUpdateRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; @Injectable() export class UpdateRecordWorkflowAction implements WorkflowAction { constructor( - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly updateRecordService: UpdateRecordService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, - private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, - private readonly recordInputTransformerService: RecordInputTransformerService, ) {} async execute({ @@ -60,97 +54,38 @@ export class UpdateRecordWorkflowAction implements WorkflowAction { !isValidUuid(workflowActionInput.objectRecordId) || !isDefined(workflowActionInput.objectName) ) { - throw new RecordCRUDActionException( + throw new RecordCrudException( 'Failed to update: Object record ID and name are required', - RecordCRUDActionExceptionCode.INVALID_REQUEST, + RecordCrudExceptionCode.INVALID_REQUEST, ); } - const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; + const { workspaceId } = this.scopedWorkspaceContextFactory.create(); if (!workspaceId) { - throw new RecordCRUDActionException( + throw new RecordCrudException( 'Failed to update: Workspace ID is required', - RecordCRUDActionExceptionCode.INVALID_REQUEST, + RecordCrudExceptionCode.INVALID_REQUEST, ); } - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - workflowActionInput.objectName, - { shouldBypassPermissionChecks: true }, - ); - - const previousObjectRecord = await repository.findOne({ - where: { - id: workflowActionInput.objectRecordId, - }, + const toolOutput = await this.updateRecordService.execute({ + objectName: workflowActionInput.objectName, + objectRecordId: workflowActionInput.objectRecordId, + objectRecord: workflowActionInput.objectRecord, + fieldsToUpdate: workflowActionInput.fieldsToUpdate, + workspaceId, }); - if (!previousObjectRecord) { - throw new RecordCRUDActionException( - `Failed to update: Record ${workflowActionInput.objectName} with id ${workflowActionInput.objectRecordId} not found`, - RecordCRUDActionExceptionCode.RECORD_NOT_FOUND, + if (!toolOutput.success) { + throw new RecordCrudException( + toolOutput.error || toolOutput.message, + RecordCrudExceptionCode.RECORD_UPDATE_FAILED, ); } - if (workflowActionInput.fieldsToUpdate.length === 0) { - return { - result: previousObjectRecord, - }; - } - - const { objectMetadataItemWithFieldsMaps } = - await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( - workflowActionInput.objectName, - workspaceId, - ); - - if ( - !canObjectBeManagedByWorkflow({ - nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, - isSystem: objectMetadataItemWithFieldsMaps.isSystem, - }) - ) { - throw new RecordCRUDActionException( - 'Failed to update: Object cannot be updated by workflow', - RecordCRUDActionExceptionCode.INVALID_REQUEST, - ); - } - - const objectRecordWithFilteredFields = Object.keys( - workflowActionInput.objectRecord, - ).reduce((acc, key) => { - if (workflowActionInput.fieldsToUpdate.includes(key)) { - return { - ...acc, - [key]: workflowActionInput.objectRecord[key], - }; - } - - return acc; - }, {}); - - const transformedObjectRecord = - await this.recordInputTransformerService.process({ - recordInput: objectRecordWithFilteredFields, - objectMetadataMapItem: objectMetadataItemWithFieldsMaps, - }); - - const updatedObjectRecord = { - ...previousObjectRecord, - ...objectRecordWithFilteredFields, - }; - - if (!deepEqual(updatedObjectRecord, previousObjectRecord)) { - await repository.update(workflowActionInput.objectRecordId, { - ...transformedObjectRecord, - }); - } - return { - result: updatedObjectRecord, + result: toolOutput.result, }; } } diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts index 885519d150d..37ae6104181 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts @@ -1,13 +1,6 @@ import { type AgentToolTestContext, createAgentToolTestModule, - createMockRepository, - createTestRecord, - createTestRecords, - expectErrorResult, - expectSuccessResult, - setupBasicPermissions, - setupRepositoryMock, } from './utils/agent-tool-test-utils'; describe('AgentToolGeneratorService Integration', () => { @@ -188,636 +181,4 @@ describe('AgentToolGeneratorService Integration', () => { expect(Object.keys(tools)).toHaveLength(7); }); }); - - describe('Create Record Operations', () => { - it('should create a record successfully', async () => { - const mockRepository = createMockRepository(); - const testRecord = createTestRecord('test-record-id', { - name: 'Test Record', - description: 'Test description', - }); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.save.mockResolvedValue(testRecord); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const createTool = tools['create_testObject']; - - expect(createTool).toBeDefined(); - - if (!createTool.execute) { - throw new Error( - 'Create tool is missing or does not have an execute method', - ); - } - - const result = await createTool.execute( - { input: { name: 'Test Record', description: 'Test description' } }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Test Record', - }, - ], - }, - ); - - expectSuccessResult(result, 'Successfully created testObject'); - expect(result.result).toEqual(testRecord); - expect(mockRepository.save).toHaveBeenCalledWith({ - name: 'Test Record', - description: 'Test description', - }); - }); - - it('should handle create record errors gracefully', async () => { - const mockRepository = createMockRepository(); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.save.mockRejectedValue( - new Error('Database constraint violation'), - ); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const createTool = tools['create_testObject']; - - expect(createTool).toBeDefined(); - - if (!createTool.execute) { - throw new Error( - 'Create tool is missing or does not have an execute method', - ); - } - - const result = await createTool.execute( - { input: { name: 'Test Record' } }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Test Record', - }, - ], - }, - ); - - expectErrorResult( - result, - 'Database constraint violation', - 'Failed to create testObject', - ); - }); - }); - - describe('Find Record Operations', () => { - it('should find records with basic parameters', async () => { - const mockRepository = createMockRepository(); - const testRecords = createTestRecords(3); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.find.mockResolvedValue(testRecords); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const findTool = tools['find_testObject']; - - expect(findTool).toBeDefined(); - - if (!findTool.execute) { - throw new Error( - 'Find tool is missing or does not have an execute method', - ); - } - - const result = await findTool.execute( - { input: { limit: 10, offset: 0 } }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Find records', - }, - ], - }, - ); - - expectSuccessResult(result, 'Found 3 testObject records'); - expect(result.result.records).toEqual(testRecords); - expect(result.result.count).toBe(3); - expect(mockRepository.find).toHaveBeenCalledWith({ - where: {}, - take: 10, - skip: 0, - order: { createdAt: 'DESC' }, - }); - }); - - it('should find one record by ID', async () => { - const mockRepository = createMockRepository(); - const testRecord = createTestRecord('test-record-id', { - name: 'Test Record', - }); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.findOne.mockResolvedValue(testRecord); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const findOneTool = tools['find_one_testObject']; - - expect(findOneTool).toBeDefined(); - - if (!findOneTool.execute) { - throw new Error( - 'Find one tool is missing or does not have an execute method', - ); - } - - const result = await findOneTool.execute( - { input: { id: 'test-record-id' } }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Find one record', - }, - ], - }, - ); - - expectSuccessResult(result, 'Found testObject record'); - expect(result.result).toEqual(testRecord); - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { id: 'test-record-id' }, - }); - }); - - it('should handle find one record not found', async () => { - const mockRepository = createMockRepository(); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.findOne.mockResolvedValue(null); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const findOneTool = tools['find_one_testObject']; - - expect(findOneTool).toBeDefined(); - - if (!findOneTool.execute) { - throw new Error( - 'Find one tool is missing or does not have an execute method', - ); - } - - const result = await findOneTool.execute( - { input: { id: 'non-existent-id' } }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Find one record', - }, - ], - }, - ); - - expectErrorResult( - result, - 'Record not found', - 'Failed to find testObject: Record with ID non-existent-id not found', - ); - }); - - it('should handle find one record without ID', async () => { - setupBasicPermissions(context); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const findOneTool = tools['find_one_testObject']; - - expect(findOneTool).toBeDefined(); - - if (!findOneTool.execute) { - throw new Error( - 'Find one tool is missing or does not have an execute method', - ); - } - - const result = await findOneTool.execute( - { input: {} }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Find one record', - }, - ], - }, - ); - - expectErrorResult( - result, - 'Record ID is required', - 'Failed to find testObject: Record ID is required', - ); - }); - }); - - describe('Update Record Operations', () => { - it('should update a record successfully', async () => { - const mockRepository = createMockRepository(); - const existingRecord = createTestRecord('test-record-id', { - name: 'Old Name', - description: 'Old description', - }); - const updatedRecord = createTestRecord('test-record-id', { - name: 'New Name', - description: 'New description', - }); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - jest - .spyOn(context.objectMetadataService, 'findOneWithinWorkspace') - .mockResolvedValue(context.testObjectMetadata); - mockRepository.findOne - .mockResolvedValueOnce(existingRecord) - .mockResolvedValueOnce(updatedRecord); - mockRepository.update.mockResolvedValue({ affected: 1 } as any); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const updateTool = tools['update_testObject']; - - expect(updateTool).toBeDefined(); - - if (!updateTool.execute) { - throw new Error( - 'Update tool is missing or does not have an execute method', - ); - } - - const result = await updateTool.execute( - { - input: { - id: 'test-record-id', - name: 'New Name', - description: 'New description', - }, - }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Update record', - }, - ], - }, - ); - - expectSuccessResult(result, 'Successfully updated testObject'); - expect(result.result).toEqual(updatedRecord); - expect(mockRepository.update).toHaveBeenCalledWith('test-record-id', { - name: 'New Name', - description: 'New description', - }); - }); - - it('should handle update record not found', async () => { - const mockRepository = createMockRepository(); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.findOne.mockResolvedValue(null); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const updateTool = tools['update_testObject']; - - expect(updateTool).toBeDefined(); - - if (!updateTool.execute) { - throw new Error( - 'Update tool is missing or does not have an execute method', - ); - } - - const result = await updateTool.execute( - { - input: { - id: 'non-existent-id', - name: 'New Name', - }, - }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Update record', - }, - ], - }, - ); - - expectErrorResult( - result, - 'Record not found', - 'Failed to update testObject: Record with ID non-existent-id not found', - ); - }); - }); - - describe('Soft Delete Operations', () => { - it('should soft delete a single record', async () => { - const mockRepository = createMockRepository(); - const existingRecord = createTestRecord('test-record-id', { - name: 'Test Record', - }); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.findOne.mockResolvedValue(existingRecord); - mockRepository.softDelete.mockResolvedValue({ affected: 1 } as any); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const softDeleteTool = tools['soft_delete_testObject']; - - expect(softDeleteTool).toBeDefined(); - - if (!softDeleteTool.execute) { - throw new Error( - 'Soft delete tool is missing or does not have an execute method', - ); - } - - const result = await softDeleteTool.execute( - { input: { id: 'test-record-id' } }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Soft delete record', - }, - ], - }, - ); - - expectSuccessResult(result, 'Successfully soft deleted testObject'); - expect(mockRepository.softDelete).toHaveBeenCalledWith('test-record-id'); - }); - - it('should soft delete multiple records', async () => { - const mockRepository = createMockRepository(); - const existingRecords = createTestRecords(3); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.find.mockResolvedValue(existingRecords); - mockRepository.softDelete.mockResolvedValue({ affected: 3 } as any); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const softDeleteManyTool = tools['soft_delete_many_testObject']; - - expect(softDeleteManyTool).toBeDefined(); - - if (!softDeleteManyTool.execute) { - throw new Error( - 'Soft delete many tool is missing or does not have an execute method', - ); - } - - const result = await softDeleteManyTool.execute( - { - input: { - filter: { id: { in: ['record-1', 'record-2', 'record-3'] } }, - }, - }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Soft delete many records', - }, - ], - }, - ); - - expectSuccessResult( - result, - 'Successfully soft deleted 3 testObject records', - ); - expect(mockRepository.softDelete).toHaveBeenCalledWith({ - id: expect.any(Object), - }); - }); - }); - - describe('Edge Cases', () => { - it('should handle empty search criteria in find records', async () => { - const mockRepository = createMockRepository(); - const testRecords = createTestRecords(2); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.find.mockResolvedValue(testRecords); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const findTool = tools['find_testObject']; - - expect(findTool).toBeDefined(); - - if (!findTool.execute) { - throw new Error( - 'Find tool is missing or does not have an execute method', - ); - } - - const result = await findTool.execute( - { input: {} }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Find records', - }, - ], - }, - ); - - expectSuccessResult(result, 'Found 2 testObject records'); - expect(mockRepository.find).toHaveBeenCalledWith({ - where: {}, - take: 100, - skip: 0, - order: { createdAt: 'DESC' }, - }); - }); - - it('should handle null and undefined values in search criteria', async () => { - const mockRepository = createMockRepository(); - const testRecords = createTestRecords(1); - - setupBasicPermissions(context); - setupRepositoryMock(context, mockRepository); - mockRepository.find.mockResolvedValue(testRecords); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - const findTool = tools['find_testObject']; - - expect(findTool).toBeDefined(); - - if (!findTool.execute) { - throw new Error( - 'Find tool is missing or does not have an execute method', - ); - } - - const result = await findTool.execute( - { - input: { - name: null, - description: undefined, - status: '', - validField: 'valid value', - }, - }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'Find records', - }, - ], - }, - ); - - expectSuccessResult(result, 'Found 1 testObject records'); - expect(mockRepository.find).toHaveBeenCalledWith({ - where: { validField: 'valid value' }, - take: 100, - skip: 0, - order: { createdAt: 'DESC' }, - }); - }); - - it('should handle multiple object metadata with different permissions', async () => { - const secondObjectMetadata = { - ...context.testObjectMetadata, - id: 'second-object-id', - nameSingular: 'secondObject', - namePlural: 'secondObjects', - labelSingular: 'Second Object', - labelPlural: 'Second Objects', - }; - - jest - .spyOn(context.agentService, 'findOneAgent') - .mockResolvedValue(context.testAgent as any); - jest - .spyOn(context.roleRepository, 'findOne') - .mockResolvedValue(context.testRole); - jest - .spyOn( - context.workspacePermissionsCacheService, - 'getRolesPermissionsFromCache', - ) - .mockResolvedValue({ - data: { - [context.testRoleId]: { - [context.testObjectMetadata.id]: { - canRead: true, - canUpdate: true, - canSoftDelete: false, - canDestroy: false, - canReadObjectRecords: true, - canUpdateObjectRecords: true, - canSoftDeleteObjectRecords: false, - canDestroyObjectRecords: false, - restrictedFields: {}, - }, - [secondObjectMetadata.id]: { - canRead: true, - canUpdate: false, - canSoftDelete: true, - canDestroy: false, - canReadObjectRecords: true, - canUpdateObjectRecords: false, - canSoftDeleteObjectRecords: true, - canDestroyObjectRecords: false, - restrictedFields: {}, - }, - }, - }, - version: '1.0', - }); - jest - .spyOn(context.objectMetadataService, 'findManyWithinWorkspace') - .mockResolvedValue([context.testObjectMetadata, secondObjectMetadata]); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - - expect(tools).toBeDefined(); - expect(Object.keys(tools)).toHaveLength(9); - expect(Object.keys(tools)).toContain('create_testObject'); - expect(Object.keys(tools)).toContain('update_testObject'); - expect(Object.keys(tools)).toContain('find_testObject'); - expect(Object.keys(tools)).toContain('find_one_testObject'); - expect(Object.keys(tools)).toContain('soft_delete_secondObject'); - expect(Object.keys(tools)).toContain('soft_delete_many_secondObject'); - expect(Object.keys(tools)).not.toContain('soft_delete_testObject'); - expect(Object.keys(tools)).not.toContain('create_secondObject'); - }); - }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts index d105d31b8f7..7fff249c73a 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts @@ -5,6 +5,10 @@ import { type Repository } from 'typeorm'; import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service'; import { ToolService } from 'src/engine/core-modules/ai/services/tool.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 { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; @@ -85,6 +89,34 @@ export const createAgentToolTestModule = provide: ToolService, useClass: ToolService, }, + { + provide: CreateRecordService, + useValue: { + execute: jest.fn(), + }, + }, + { + provide: UpdateRecordService, + useValue: { + execute: jest.fn(), + }, + }, + { + provide: DeleteRecordService, + useValue: { + execute: jest.fn(), + }, + }, + { + provide: FindRecordsService, + useValue: { + execute: jest.fn().mockResolvedValue({ + success: true, + message: 'Records found successfully', + result: [], + }), + }, + }, { provide: RecordInputTransformerService, useValue: {