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 de82ba5ccf..46657834c1 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,6 +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 { 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'; @@ -28,6 +29,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module'; TypeOrmModule.forFeature([RoleEntity], 'core'), TokenModule, FeatureFlagModule, + RecordTransformerModule, ObjectMetadataModule, WorkspacePermissionsCacheModule, WorkspaceCacheStorageModule, diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/ai-billing.service.spec.ts similarity index 93% rename from packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/ai/services/__tests__/ai-billing.service.spec.ts index 5e1346ec3c..af8ec86849 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/ai-billing.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/ai-billing.service.spec.ts @@ -3,9 +3,8 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant'; import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; - -import { AIBillingService } from './ai-billing.service'; -import { AiModelRegistryService } from './ai-model-registry.service'; +import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service'; +import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service'; describe('AIBillingService', () => { let service: AIBillingService; diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/mcp.service.spec.ts similarity index 99% rename from packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/ai/services/__tests__/mcp.service.spec.ts index 9391562379..73c1a7c8e3 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/mcp.service.spec.ts @@ -11,8 +11,7 @@ import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc'; import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const'; import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; - -import { McpService } from './mcp.service'; +import { McpService } from 'src/engine/core-modules/ai/services/mcp.service'; describe('McpService', () => { let service: McpService; diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/tool-adapter.service.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/tool-adapter.service.spec.ts new file mode 100644 index 0000000000..df210a8ca3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/tool-adapter.service.spec.ts @@ -0,0 +1,157 @@ +import { Test } from '@nestjs/testing'; + +import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service'; +import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; +import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; +import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; + +const createMockToolRegistry = () => ({ + getAllToolTypes: jest.fn(), + getTool: jest.fn(), +}); + +const createMockPermissions = () => ({ + hasToolPermission: jest.fn< + Promise, + [string, string, PermissionFlagType] + >(), +}); + +describe('ToolAdapterService', () => { + let mockRegistry: ReturnType; + let mockPermissions: ReturnType; + let service: ToolAdapterService; + + // Shared tools + const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({ + result: { echoed: input }, + })); + const unflaggedTool: Tool = { + description: 'HTTP Request tool', + parameters: { type: 'object', properties: {} }, + execute: unflaggedToolExecute, + }; + + const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({ + result: { sent: input }, + })); + const flaggedTool: Tool = { + description: 'Send Email tool', + parameters: { type: 'object', properties: {} }, + execute: flaggedToolExecute, + flag: PermissionFlagType.SEND_EMAIL_TOOL, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + mockRegistry = createMockToolRegistry(); + mockPermissions = createMockPermissions(); + + // Setup mock tool responses + mockRegistry.getAllToolTypes.mockReturnValue([ + ToolType.HTTP_REQUEST, + ToolType.SEND_EMAIL, + ]); + mockRegistry.getTool.mockImplementation((type: ToolType) => { + if (type === ToolType.HTTP_REQUEST) return unflaggedTool; + if (type === ToolType.SEND_EMAIL) return flaggedTool; + throw new Error('Tool not found in mock'); + }); + + const moduleRef = await Test.createTestingModule({ + providers: [ + ToolAdapterService, + { + provide: ToolRegistryService, + useValue: mockRegistry, + }, + { + provide: PermissionsService, + useValue: mockPermissions, + }, + ], + }).compile(); + + service = moduleRef.get(ToolAdapterService); + }); + + it('should include unflagged tools regardless of role/workspace', async () => { + const toolsNoContext = await service.getTools(); + + expect(Object.keys(toolsNoContext)).toContain('http_request'); + + const toolsWithPartialContext = await service.getTools('role-1'); + + expect(Object.keys(toolsWithPartialContext)).toContain('http_request'); + }); + + it('should not include flagged tools when role/workspace are missing', async () => { + const toolsNoContext = await service.getTools(); + + expect(Object.keys(toolsNoContext)).not.toContain('send_email'); + + const toolsRoleOnly = await service.getTools('role-1'); + + expect(Object.keys(toolsRoleOnly)).not.toContain('send_email'); + + const toolsWorkspaceOnly = await service.getTools(undefined, 'ws-1'); + + expect(Object.keys(toolsWorkspaceOnly)).not.toContain('send_email'); + }); + + it('should include flagged tools when permission is granted', async () => { + mockPermissions.hasToolPermission.mockResolvedValueOnce(true); + + const tools = await service.getTools('role-1', 'ws-1'); + + expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith( + 'role-1', + 'ws-1', + PermissionFlagType.SEND_EMAIL_TOOL, + ); + + expect(Object.keys(tools)).toContain('send_email'); + }); + + it('should exclude flagged tools when permission is denied', async () => { + mockPermissions.hasToolPermission.mockResolvedValueOnce(false); + + const tools = await service.getTools('role-1', 'ws-1'); + + expect(Object.keys(tools)).not.toContain('send_email'); + }); + + it('should lowercase tool type keys in the returned ToolSet', async () => { + const tools = await service.getTools(); + + const keys = Object.keys(tools); + + expect(keys).toContain('http_request'); + expect(keys).not.toContain(ToolType.HTTP_REQUEST); // ensure enum raw value not used as-is + }); + + it('should forward execute input correctly and return underlying result', async () => { + const tools = await service.getTools(); + + const input = { url: 'https://example.com', method: 'GET' } as ToolInput; + const result = await tools['http_request'].execute?.( + { input }, + { + toolCallId: 'test-tool-call-id', + messages: [ + { + role: 'user', + content: 'content', + }, + ], + }, + ); + + // Ensure wrapper forwards only parameters.input + expect(unflaggedToolExecute).toHaveBeenCalledWith(input); + expect(result).toEqual({ result: { echoed: input } }); + }); +}); 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 new file mode 100644 index 0000000000..3847afdbe1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/services/__tests__/tool.service.spec.ts @@ -0,0 +1,213 @@ +import { Test } from '@nestjs/testing'; + +import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +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'; +import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock'; + +// Minimal mock repository type +const createMockRepository = () => ({ + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + delete: jest.fn(), + remove: jest.fn(), +}); + +describe('ToolService', () => { + const workspaceId = 'ws_1'; + const roleId = 'role_1'; + + let service: ToolService; + let ormManager: TwentyORMGlobalManager; + let permissionsCacheService: WorkspacePermissionsCacheService; + let transformer: RecordInputTransformerService; + let workspaceCache: WorkspaceCacheStorageService; + + const testObject = getMockObjectMetadataEntity({ + workspaceId: '', + id: 'obj_1', + nameSingular: 'testObject', + namePlural: 'testObjects', + labelSingular: 'Test Object', + labelPlural: 'Test Objects', + isActive: true, + isSystem: false, + fields: [], + }); + + const mockRepo = createMockRepository(); + + beforeEach(async () => { + jest.resetAllMocks(); + + const moduleRef = await Test.createTestingModule({ + providers: [ + ToolService, + { + provide: TwentyORMGlobalManager, + useValue: { + getRepositoryForWorkspace: jest.fn().mockResolvedValue(mockRepo), + }, + }, + { + provide: ObjectMetadataService, + useValue: { + findManyWithinWorkspace: jest.fn().mockResolvedValue([testObject]), + }, + }, + { + provide: WorkspacePermissionsCacheService, + useValue: { + getRolesPermissionsFromCache: jest.fn().mockResolvedValue({ + data: { + [roleId]: { + [testObject.id]: { + canRead: true, + canUpdate: true, + canSoftDelete: true, + canDestroy: false, + restrictedFields: {}, + }, + }, + }, + }), + }, + }, + { + provide: RecordInputTransformerService, + useValue: { + process: jest.fn(async ({ recordInput }) => recordInput), + }, + }, + { + provide: WorkspaceCacheStorageService, + useValue: { + getObjectMetadataMapsOrThrow: jest.fn().mockResolvedValue({ + byId: { + [testObject.id]: { + ...testObject, + fieldsById: {}, + fieldIdByJoinColumnName: {}, + fieldIdByName: {}, + indexMetadatas: [], + }, + }, + idByNameSingular: { [testObject.nameSingular]: testObject.id }, + }), + }, + }, + ], + }).compile(); + + service = moduleRef.get(ToolService); + ormManager = moduleRef.get(TwentyORMGlobalManager); + permissionsCacheService = moduleRef.get(WorkspacePermissionsCacheService); + transformer = moduleRef.get(RecordInputTransformerService); + workspaceCache = moduleRef.get(WorkspaceCacheStorageService); + }); + + describe('listTools', () => { + it('should return tools based on role permissions', async () => { + const tools = await service.listTools(roleId, workspaceId); + + expect( + permissionsCacheService.getRolesPermissionsFromCache, + ).toHaveBeenCalledWith({ workspaceId }); + + // Verify tool keys + expect(tools['create_testObject']).toBeDefined(); + expect(tools['update_testObject']).toBeDefined(); + expect(tools['find_testObject']).toBeDefined(); + expect(tools['find_one_testObject']).toBeDefined(); + expect(tools['soft_delete_testObject']).toBeDefined(); + expect(tools['soft_delete_many_testObject']).toBeDefined(); + + // Ensure the execute functions are wired + expect(typeof tools['create_testObject'].execute).toBe('function'); + }); + }); + + 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.record).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.records).toEqual(records); + expect(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( + 'testObject', + {}, + workspaceId, + roleId, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe( + 'Filter with record IDs is required for bulk soft delete', + ); + }); + }); +}); 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 d79e0f3cd1..637bd18118 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 @@ -1,18 +1,9 @@ import { Injectable } from '@nestjs/common'; import { type ToolSet } from 'ai'; -import { - ILike, - In, - IsNull, - LessThan, - LessThanOrEqual, - Like, - MoreThan, - MoreThanOrEqual, - Not, -} from 'typeorm'; +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 { generateBulkDeleteToolSchema, generateFindOneToolSchema, @@ -22,8 +13,10 @@ import { } from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils'; import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-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 { @@ -31,6 +24,8 @@ export class ToolService { private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly objectMetadataService: ObjectMetadataService, protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService, + private readonly recordInputTransformerService: RecordInputTransformerService, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, ) {} async listTools(roleId: string, workspaceId: string): Promise { @@ -167,7 +162,7 @@ export class ToolService { const { limit = 100, offset = 0, ...searchCriteria } = parameters; - const whereConditions = this.buildWhereConditions(searchCriteria); + const whereConditions = buildWhereConditions(searchCriteria); const records = await repository.find({ where: whereConditions, @@ -191,124 +186,6 @@ export class ToolService { } } - private buildWhereConditions( - searchCriteria: Record, - ): Record { - const whereConditions: Record = {}; - - Object.entries(searchCriteria).forEach(([key, value]) => { - if (value === undefined || value === null || value === '') { - return; - } - - if (typeof value === 'object' && !Array.isArray(value)) { - const nestedConditions = this.buildNestedWhereConditions( - value as Record, - ); - - if (Object.keys(nestedConditions).length > 0) { - whereConditions[key] = nestedConditions; - } else { - const filterCondition = this.parseFilterCondition( - value as Record, - ); - - if (filterCondition !== null) { - whereConditions[key] = filterCondition; - } - } - - return; - } - - whereConditions[key] = value; - }); - - return whereConditions; - } - - private buildNestedWhereConditions( - nestedValue: Record, - ): Record { - const nestedConditions: Record = {}; - - Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => { - if ( - nestedFieldValue === undefined || - nestedFieldValue === null || - nestedFieldValue === '' - ) { - return; - } - - if ( - typeof nestedFieldValue === 'object' && - !Array.isArray(nestedFieldValue) - ) { - const filterCondition = this.parseFilterCondition( - nestedFieldValue as Record, - ); - - if (filterCondition !== null) { - nestedConditions[nestedKey] = filterCondition; - } - } else { - nestedConditions[nestedKey] = nestedFieldValue; - } - }); - - return nestedConditions; - } - - private parseFilterCondition(filterValue: Record): unknown { - if ('eq' in filterValue) { - return filterValue.eq; - } - if ('neq' in filterValue) { - return Not(filterValue.neq); - } - if ('gt' in filterValue) { - return MoreThan(filterValue.gt); - } - if ('gte' in filterValue) { - return MoreThanOrEqual(filterValue.gte); - } - if ('lt' in filterValue) { - return LessThan(filterValue.lt); - } - if ('lte' in filterValue) { - return LessThanOrEqual(filterValue.lte); - } - if ('in' in filterValue) { - return In(filterValue.in as string[]); - } - if ('like' in filterValue) { - return Like(filterValue.like as string); - } - if ('ilike' in filterValue) { - return ILike(filterValue.ilike as string); - } - if ('startsWith' in filterValue) { - return Like(`${filterValue.startsWith}%`); - } - if ('is' in filterValue) { - if (filterValue.is === 'NULL') { - return IsNull(); - } - if (filterValue.is === 'NOT_NULL') { - return Not(IsNull()); - } - } - if ('isEmptyArray' in filterValue) { - return []; - } - if ('containsIlike' in filterValue) { - return Like(`%${filterValue.containsIlike}%`); - } - - return null; - } - private async findOneRecord( objectName: string, parameters: Record, @@ -373,7 +250,29 @@ export class ToolService { { roleId }, ); - const createdRecord = await repository.save(parameters); + const objectMetadataMaps = + await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow( + workspaceId, + ); + + const objectMetadataItemWithFieldsMaps = + getObjectMetadataMapItemByNameSingular(objectMetadataMaps, objectName); + + if (!objectMetadataItemWithFieldsMaps) { + return { + success: false, + error: 'Object metadata not found', + message: `Failed to create ${objectName}: Object metadata not found`, + }; + } + + const transformedCreateData = + await this.recordInputTransformerService.process({ + recordInput: parameters, + objectMetadataMapItem: objectMetadataItemWithFieldsMaps, + }); + + const createdRecord = await repository.save(transformedCreateData); return { success: true, @@ -425,7 +324,29 @@ export class ToolService { }; } - await repository.update(id as string, updateData); + const objectMetadataMaps = + await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow( + workspaceId, + ); + + const objectMetadataItemWithFieldsMaps = + getObjectMetadataMapItemByNameSingular(objectMetadataMaps, objectName); + + if (!objectMetadataItemWithFieldsMaps) { + return { + success: false, + error: 'Object metadata not found', + message: `Failed to update ${objectName}: 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 }, diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/__tests__/find-records-filters.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/__tests__/find-records-filters.utils.spec.ts new file mode 100644 index 0000000000..8ecb5aa540 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/__tests__/find-records-filters.utils.spec.ts @@ -0,0 +1,120 @@ +import { + ILike, + In, + IsNull, + LessThan, + LessThanOrEqual, + Like, + MoreThan, + MoreThanOrEqual, + Not, +} from 'typeorm'; + +import { + buildWhereConditions, + parseFilterCondition, +} from 'src/engine/core-modules/ai/utils/find-records-filters.utils'; + +describe('find-records-filters.utils', () => { + describe('parseFilterCondition', () => { + it('should handle eq', () => { + expect(parseFilterCondition({ eq: 10 })).toBe(10); + }); + + it('should handle neq', () => { + expect(parseFilterCondition({ neq: 5 })).toEqual(Not(5)); + }); + + it('should handle gt/gte/lt/lte', () => { + expect(parseFilterCondition({ gt: 1 })).toEqual(MoreThan(1)); + expect(parseFilterCondition({ gte: 2 })).toEqual(MoreThanOrEqual(2)); + expect(parseFilterCondition({ lt: 3 })).toEqual(LessThan(3)); + expect(parseFilterCondition({ lte: 4 })).toEqual(LessThanOrEqual(4)); + }); + + it('should handle in', () => { + expect(parseFilterCondition({ in: ['a', 'b'] })).toEqual(In(['a', 'b'])); + }); + + it('should handle like/ilike', () => { + expect(parseFilterCondition({ like: '%foo%' })).toEqual(Like('%foo%')); + expect(parseFilterCondition({ ilike: '%bar%' })).toEqual(ILike('%bar%')); + }); + + it('should handle startsWith', () => { + expect(parseFilterCondition({ startsWith: 'pre' })).toEqual(Like('pre%')); + }); + + it('should handle is NULL and NOT_NULL', () => { + expect(parseFilterCondition({ is: 'NULL' })).toEqual(IsNull()); + expect(parseFilterCondition({ is: 'NOT_NULL' })).toEqual(Not(IsNull())); + }); + + it('should handle isEmptyArray', () => { + expect(parseFilterCondition({ isEmptyArray: true })).toEqual([]); + }); + + it('should handle containsIlike (uses Like with wildcards)', () => { + const result = parseFilterCondition({ containsIlike: 'mid' }); + + expect(result).toEqual(Like('%mid%')); + }); + }); + + describe('buildWhereConditions', () => { + it('should build where conditions from mixed criteria', () => { + const where = buildWhereConditions({ + // primitive values + id: '123', + active: true, + count: 7, + // skip falsy-empty values + skipUndefined: undefined, + skipNull: null, + skipEmptyString: '', + // operator objects + name: { ilike: '%alpha%' }, + createdAt: { gte: '2024-01-01' }, + score: { lte: 99 }, + tags: { in: ['a', 'b'] }, + prefix: { startsWith: 'PRE' }, + nothing: { is: 'NULL' }, + notNothing: { is: 'NOT_NULL' }, + contains: { containsIlike: 'mid' }, + // nested relation-like object + account: { + name: { startsWith: 'ACME' }, + size: { gte: 100 }, + country: '', // should be omitted + }, + // arrays should pass through when not an operator object + list: [1, 2, 3], + }); + + expect(where.id).toBe('123'); + expect(where.active).toBe(true); + expect(where.count).toBe(7); + + expect(where.name).toEqual(ILike('%alpha%')); + expect(where.createdAt).toEqual(MoreThanOrEqual('2024-01-01')); + expect(where.score).toEqual(LessThanOrEqual(99)); + expect(where.tags).toEqual(In(['a', 'b'])); + expect(where.prefix).toEqual(Like('PRE%')); + expect(where.nothing).toEqual(IsNull()); + expect(where.notNothing).toEqual(Not(IsNull())); + expect(where.contains).toEqual(Like('%mid%')); + + expect(where.account).toEqual({ + name: Like('ACME%'), + size: MoreThanOrEqual(100), + }); + + expect(where.list).toEqual([1, 2, 3]); + + // Ensure skipped values are not present + expect('skipUndefined' in where).toBe(false); + expect('skipNull' in where).toBe(false); + expect('skipEmptyString' in where).toBe(false); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/find-records-filters.utils.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/find-records-filters.utils.ts new file mode 100644 index 0000000000..ae1827ca87 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/ai/utils/find-records-filters.utils.ts @@ -0,0 +1,112 @@ +import { + ILike, + In, + IsNull, + LessThan, + LessThanOrEqual, + Like, + MoreThan, + MoreThanOrEqual, + Not, +} from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; + +type FilterObject = Record; + +type WhereRecord = Record; + +const isSkippableValue = (value: unknown): boolean => + !isDefined(value) || value === ''; + +const isPlainObject = (value: unknown): value is FilterObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const parseFilterCondition = (filterValue: FilterObject): unknown => { + if ('eq' in filterValue) { + return filterValue.eq; + } + if ('neq' in filterValue) { + return Not(filterValue.neq as unknown); + } + if ('gt' in filterValue) { + return MoreThan(filterValue.gt as unknown as number | string | Date); + } + if ('gte' in filterValue) { + return MoreThanOrEqual( + filterValue.gte as unknown as number | string | Date, + ); + } + if ('lt' in filterValue) { + return LessThan(filterValue.lt as unknown as number | string | Date); + } + if ('lte' in filterValue) { + return LessThanOrEqual( + filterValue.lte as unknown as number | string | Date, + ); + } + if ('in' in filterValue) { + const values = (filterValue as { in: unknown }).in; + + return Array.isArray(values) ? In(values as unknown[]) : null; + } + if ('like' in filterValue) { + return Like(filterValue.like as string); + } + if ('ilike' in filterValue) { + return ILike(filterValue.ilike as string); + } + if ('startsWith' in filterValue) { + return Like(`${String(filterValue.startsWith)}%`); + } + if ('is' in filterValue) { + const v = (filterValue as { is: unknown }).is; + + if (v === 'NULL') return IsNull(); + if (v === 'NOT_NULL') return Not(IsNull()); + } + if ('isEmptyArray' in filterValue) { + return []; + } + if ('containsIlike' in filterValue) { + return Like(`%${String(filterValue.containsIlike)}%`); + } + + return null; +}; + +export const buildWhereConditions = ( + searchCriteria: FilterObject, +): WhereRecord => { + return Object.entries(searchCriteria).reduce( + (acc, [key, value]) => { + if (isSkippableValue(value)) { + return acc; + } + + if (isPlainObject(value)) { + // Direct operator-based condition (eq, ilike, etc.) + const filterCondition = parseFilterCondition(value as FilterObject); + + if (isDefined(filterCondition)) { + acc[key] = filterCondition; + + return acc; + } + + // Otherwise, try to build nested conditions + const nestedConditions = buildWhereConditions(value); + + if (Object.keys(nestedConditions).length > 0) { + acc[key] = nestedConditions; + } + + return acc; + } + + acc[key] = value as unknown; + + return acc; + }, + {}, + ); +}; 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 e6bdf5a956..c944eacd9f 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 @@ -21,6 +21,8 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service'; import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock'; +import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; export interface AgentToolTestContext { module: TestingModule; @@ -82,6 +84,18 @@ export const createAgentToolTestModule = provide: ToolService, useClass: ToolService, }, + { + provide: RecordInputTransformerService, + useValue: { + process: jest.fn(async ({ recordInput }) => recordInput), + }, + }, + { + provide: WorkspaceCacheStorageService, + useValue: { + getObjectMetadataMapsOrThrow: jest.fn(), + }, + }, { provide: ToolAdapterService, useClass: ToolAdapterService, @@ -212,6 +226,29 @@ export const createAgentToolTestModule = fieldPermissions: [], }); + // Ensure ToolService input transformation has access to minimal metadata maps + const workspaceCacheStorageService = + module.get(WorkspaceCacheStorageService); + + // Return a barebones object metadata map where fields are unknown (so transformer is a no-op) + const getMapsMock = + workspaceCacheStorageService.getObjectMetadataMapsOrThrow as jest.Mock; + + getMapsMock.mockResolvedValue({ + byId: { + [testObjectMetadata.id]: { + ...testObjectMetadata, + fieldsById: {}, + fieldIdByJoinColumnName: {}, + fieldIdByName: {}, + indexMetadatas: [], + }, + }, + idByNameSingular: { + [testObjectMetadata.nameSingular]: testObjectMetadata.id, + }, + }); + return { module, agentToolService,