From b59235409ed610aa87f1a4c58df9a8805e5dc387 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 2 Jul 2025 17:29:52 +0200 Subject: [PATCH] Turn filter action into conditions (#13005) Previous logic was using the previous step output and filtering items that were passing filters. What we actually want is: - send filters, right operand being always a step output key, left operand being either a key, either a value - resolve those filter variables - apply the filters to decide whether the condition is passed or not --- ...workflow-version-step.workspace-service.ts | 3 +- .../filter/filter.workflow-action.ts | 40 +- .../workflow-filter-action-settings.type.ts | 41 +- .../utils/__tests__/apply-filter.util.spec.ts | 324 ------- .../evaluate-filter-conditions.util.spec.ts | 805 ++++++++++++++++++ .../get-previous-step-output.util.spec.ts | 66 -- .../filter/utils/apply-filter.util.ts | 179 ---- .../utils/evaluate-filter-conditions.util.ts | 155 ++++ .../utils/get-previous-step-output.util.ts | 42 - ...ct-records-permissions.integration-spec.ts | 12 +- ...ct-records-permissions.integration-spec.ts | 12 +- 11 files changed, 1036 insertions(+), 643 deletions(-) delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/apply-filter.util.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/get-previous-step-output.util.spec.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/get-previous-step-output.util.ts diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index 2067b585e..a21f3dae2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -585,7 +585,8 @@ export class WorkflowVersionStepWorkspaceService { settings: { ...BASE_STEP_DEFINITION, input: { - filter: {}, + filterGroups: [], + filters: [], }, }, }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts index 13bb1090c..8be64e4f6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts @@ -8,10 +8,9 @@ import { } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input'; import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type'; +import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util'; import { isWorkflowFilterAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard'; -import { applyFilter } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util'; - -import { getPreviousStepOutput } from './utils/get-previous-step-output.util'; +import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util'; @Injectable() export class FilterWorkflowAction implements WorkflowExecutor { @@ -34,37 +33,30 @@ export class FilterWorkflowAction implements WorkflowExecutor { ); } - const { filter } = step.settings.input; + const { filterGroups, filters } = step.settings.input; - if (!filter) { + if (!filterGroups || !filters) { throw new WorkflowStepExecutorException( 'Filter is not defined', WorkflowStepExecutorExceptionCode.INVALID_STEP_SETTINGS, ); } - const previousStepOutput = getPreviousStepOutput( - steps, - currentStepId, - context, - ); + const resolvedFilters = filters.map((filter) => ({ + ...filter, + rightOperand: resolveInput(filter.value, context), + leftOperand: resolveInput(filter.stepOutputKey, context), + })); - const isPreviousStepOutputArray = Array.isArray(previousStepOutput); - - const previousStepOutputArray = isPreviousStepOutputArray - ? previousStepOutput - : [previousStepOutput]; - - const filteredOutput = applyFilter(previousStepOutputArray, filter); - - if (filteredOutput.length === 0) { - return { - result: undefined, - }; - } + const matchesFilter = evaluateFilterConditions({ + filterGroups, + filters: resolvedFilters, + }); return { - result: isPreviousStepOutputArray ? filteredOutput : filteredOutput[0], + result: { + matchesFilter, + }, }; } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type.ts index 8674d85f1..999aadfad 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type.ts @@ -1,9 +1,44 @@ -import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; +export enum LogicalOperator { + AND = 'AND', + OR = 'OR', +} + +export enum Operand { + EQ = 'eq', + NE = 'ne', + GT = 'gt', + GTE = 'gte', + LT = 'lt', + LTE = 'lte', + LIKE = 'like', + ILIKE = 'ilike', + IN = 'in', + IS = 'is', +} + +export type FilterGroup = { + id: string; + logicalOperator: LogicalOperator; + parentRecordFilterGroupId?: string; + positionInRecordFilterGroup?: number; +}; + +export type Filter = { + id: string; + type: string; + label: string; + value: string; + operand: Operand; + displayValue: string; + recordFilterGroupId: string; + stepOutputKey: string; +}; + export type WorkflowFilterActionSettings = BaseWorkflowActionSettings & { input: { - filter: Partial; + filterGroups?: FilterGroup[]; + filters?: Filter[]; }; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/apply-filter.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/apply-filter.util.spec.ts deleted file mode 100644 index 227526ec9..000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/apply-filter.util.spec.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { applyFilter } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util'; - -describe('applyFilter', () => { - const testData = [ - { - name: { firstName: 'John', lastName: 'Doe' }, - companyId: '20202020-171e-4bcc-9cf7-43448d6fb278', - emails: { primaryEmail: 'john.doe@example.com' }, - city: 'New York', - age: 30, - tags: ['developer', 'senior'], - metadata: { lastLogin: '2024-03-15', isActive: true }, - }, - { - name: { firstName: 'Jane', lastName: 'Smith' }, - companyId: '30303030-171e-4bcc-9cf7-43448d6fb278', - emails: { primaryEmail: 'jane.smith@test.com' }, - city: null, - age: 25, - tags: ['designer', 'junior'], - metadata: { lastLogin: '2024-03-10', isActive: false }, - }, - { - name: { firstName: 'Tom', lastName: 'Cruise' }, - companyId: '20202020-171e-4bcc-9cf7-43448d6fb278', - emails: { primaryEmail: 'tom.cruise@example.com' }, - city: '', - age: 40, - tags: ['manager', 'senior'], - metadata: { lastLogin: '2024-03-20', isActive: true }, - }, - ]; - - describe('Logical Operators', () => { - it('should handle AND conditions', () => { - const filter = { - and: [{ age: { gt: 25 } }, { metadata: { isActive: { eq: true } } }], - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([ - testData[0], // John Doe - testData[2], // Tom Cruise - ]); - }); - - it('should handle OR conditions', () => { - const filter = { - or: [{ age: { lt: 30 } }, { city: { is: 'NULL' } }], - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([ - testData[1], // Jane Smith - ]); - }); - - it('should handle NOT conditions', () => { - const filter = { - not: { - metadata: { isActive: { eq: true } }, - }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([ - testData[1], // Jane Smith - ]); - }); - - it('should handle complex nested logical operators', () => { - const filter = { - and: [ - { - or: [{ age: { gt: 35 } }, { city: { is: 'NULL' } }], - }, - { - not: { - metadata: { isActive: { eq: false } }, - }, - }, - ], - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([ - testData[2], // Tom Cruise - ]); - }); - }); - - describe('Comparison Operators', () => { - it('should handle eq operator', () => { - const filter = { - age: { eq: 30 }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0]]); - }); - - it('should handle ne operator', () => { - const filter = { - age: { ne: 30 }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[1], testData[2]]); - }); - - it('should handle gt operator', () => { - const filter = { - age: { gt: 35 }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[2]]); - }); - - it('should handle gte operator', () => { - const filter = { - age: { gte: 30 }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0], testData[2]]); - }); - - it('should handle lt operator', () => { - const filter = { - age: { lt: 30 }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[1]]); - }); - - it('should handle lte operator', () => { - const filter = { - age: { lte: 30 }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0], testData[1]]); - }); - }); - - describe('String Operators', () => { - it('should handle like operator', () => { - const filter = { - name: { - firstName: { like: 'J%' }, - }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0], testData[1]]); - }); - - it('should handle ilike operator', () => { - const filter = { - name: { - firstName: { ilike: 'j%' }, - }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0], testData[1]]); - }); - - it('should handle like with multiple wildcards', () => { - const filter = { - emails: { - primaryEmail: { like: '%@example.com' }, - }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0], testData[2]]); - }); - }); - - describe('Array and Null Operators', () => { - it('should handle in operator', () => { - const filter = { - companyId: { - in: ['20202020-171e-4bcc-9cf7-43448d6fb278'], - }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0], testData[2]]); - }); - - it('should handle is NULL operator', () => { - const filter = { - city: { is: 'NULL' }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[1]]); - }); - - it('should handle array contains', () => { - const filter = { - tags: { - in: ['senior'], - }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0], testData[2]]); - }); - }); - - describe('Nested Object Conditions', () => { - it('should handle deeply nested conditions', () => { - const filter = { - and: [ - { - name: { - firstName: { like: 'J%' }, - lastName: { like: 'D%' }, - }, - }, - { - metadata: { - isActive: { eq: true }, - lastLogin: { like: '2024-03-15' }, - }, - }, - ], - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[0]]); - }); - - it('should handle multiple levels of nesting', () => { - const filter = { - and: [ - { - name: { - firstName: { like: 'T%' }, - }, - }, - { - metadata: { - isActive: { eq: true }, - }, - }, - { - emails: { - primaryEmail: { like: '%@example.com' }, - }, - }, - ], - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([testData[2]]); - }); - }); - - describe('Edge Cases', () => { - it('should handle empty filter', () => { - const filter = {}; - - const result = applyFilter(testData, filter); - - expect(result).toEqual(testData); - }); - - it('should handle empty array input', () => { - const filter = { - age: { gt: 30 }, - }; - - const result = applyFilter([], filter); - - expect(result).toEqual([]); - }); - - it('should handle non-existent fields', () => { - const filter = { - nonExistentField: { eq: 'value' }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual([]); - }); - - it('should handle null values in nested objects', () => { - const filter = { - metadata: { - nonExistentField: { is: 'NULL' }, - }, - }; - - const result = applyFilter(testData, filter); - - expect(result).toEqual(testData); - }); - }); -}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts new file mode 100644 index 000000000..231ca3835 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts @@ -0,0 +1,805 @@ +import { + FilterGroup, + LogicalOperator, + Operand, +} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type'; +import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util'; + +type ResolvedFilter = { + id: string; + type: string; + label: string; + rightOperand: unknown; + operand: Operand; + displayValue: string; + fieldMetadataId: string; + recordFilterGroupId: string; + leftOperand: unknown; +}; + +describe('evaluateFilterConditions', () => { + describe('empty inputs', () => { + it('should return true when no filters or groups are provided', () => { + const result = evaluateFilterConditions({ + filterGroups: [], + filters: [], + }); + + expect(result).toBe(true); + }); + + it('should return true when inputs are undefined', () => { + const result = evaluateFilterConditions({}); + + expect(result).toBe(true); + }); + }); + + describe('single filter operands', () => { + const createFilter = ( + operand: Operand, + leftOperand: unknown, + rightOperand: unknown, + ): ResolvedFilter => ({ + id: 'filter1', + type: 'text', + label: 'Test Filter', + rightOperand, + operand, + displayValue: String(rightOperand), + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand, + }); + + describe('eq operand', () => { + it('should return true when values are equal', () => { + const filter = createFilter(Operand.EQ, 'John', 'John'); + const result = evaluateFilterConditions({ filters: [filter] }); + + expect(result).toBe(true); + }); + + it('should return true when values are loosely equal', () => { + const filter = createFilter(Operand.EQ, '123', 123); + const result = evaluateFilterConditions({ filters: [filter] }); + + expect(result).toBe(true); + }); + + it('should return false when values are not equal', () => { + const filter = createFilter(Operand.EQ, 'John', 'Jane'); + const result = evaluateFilterConditions({ filters: [filter] }); + + expect(result).toBe(false); + }); + }); + + describe('ne operand', () => { + it('should return false when values are equal', () => { + const filter = createFilter(Operand.NE, 'John', 'John'); + const result = evaluateFilterConditions({ filters: [filter] }); + + expect(result).toBe(false); + }); + + it('should return true when values are not equal', () => { + const filter = createFilter(Operand.NE, 'John', 'Jane'); + const result = evaluateFilterConditions({ filters: [filter] }); + + expect(result).toBe(true); + }); + }); + + describe('numeric operands', () => { + it('should handle gt operand correctly', () => { + const filter1 = createFilter(Operand.GT, 30, 25); + const filter2 = createFilter(Operand.GT, 20, 25); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false); + }); + + it('should handle gte operand correctly', () => { + const filter1 = createFilter(Operand.GTE, 25, 25); + const filter2 = createFilter(Operand.GTE, 30, 25); + const filter3 = createFilter(Operand.GTE, 20, 25); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false); + }); + + it('should handle lt operand correctly', () => { + const filter1 = createFilter(Operand.LT, 20, 25); + const filter2 = createFilter(Operand.LT, 30, 25); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false); + }); + + it('should handle lte operand correctly', () => { + const filter1 = createFilter(Operand.LTE, 25, 25); + const filter2 = createFilter(Operand.LTE, 20, 25); + const filter3 = createFilter(Operand.LTE, 30, 25); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false); + }); + + it('should convert string numbers for numeric comparisons', () => { + const filter1 = createFilter(Operand.GT, '30', '25'); + const filter2 = createFilter(Operand.LT, '20', '25'); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); + }); + }); + + describe('string operands', () => { + it('should handle like operand correctly', () => { + const filter1 = createFilter(Operand.LIKE, 'Hello World', 'World'); + const filter2 = createFilter(Operand.LIKE, 'Hello', 'World'); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false); + }); + + it('should handle ilike operand correctly (case insensitive)', () => { + const filter1 = createFilter(Operand.ILIKE, 'Hello World', 'WORLD'); + const filter2 = createFilter(Operand.ILIKE, 'Hello World', 'world'); + const filter3 = createFilter(Operand.ILIKE, 'Hello', 'WORLD'); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false); + }); + }); + + describe('in operand', () => { + it('should handle JSON array values', () => { + const filter1 = createFilter( + Operand.IN, + 'apple', + '["apple", "banana", "cherry"]', + ); + const filter2 = createFilter( + Operand.IN, + 'grape', + '["apple", "banana", "cherry"]', + ); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false); + }); + + it('should handle comma-separated string values when JSON parsing fails', () => { + const filter1 = createFilter( + Operand.IN, + 'apple', + 'apple, banana, cherry', + ); + const filter2 = createFilter( + Operand.IN, + 'grape', + 'apple, banana, cherry', + ); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false); + }); + + it('should handle comma-separated values with whitespace', () => { + const filter = createFilter( + Operand.IN, + 'apple', + ' apple , banana , cherry ', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + }); + + it('should return false for non-array JSON values', () => { + const filter = createFilter(Operand.IN, 'apple', '{"key": "value"}'); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(false); + }); + }); + + describe('is operand', () => { + it('should handle null checks', () => { + const filter1 = createFilter(Operand.IS, null, 'null'); + const filter2 = createFilter(Operand.IS, undefined, 'NULL'); + const filter3 = createFilter(Operand.IS, 'value', 'null'); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false); + }); + + it('should handle not null checks', () => { + const filter1 = createFilter(Operand.IS, 'value', 'not null'); + const filter2 = createFilter(Operand.IS, 'value', 'NOT NULL'); + const filter3 = createFilter(Operand.IS, null, 'not null'); + const filter4 = createFilter(Operand.IS, undefined, 'not null'); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter3] })).toBe(false); + expect(evaluateFilterConditions({ filters: [filter4] })).toBe(false); + }); + + it('should handle exact value comparisons for non-null/not-null cases', () => { + const filter1 = createFilter(Operand.IS, 'exact', 'exact'); + const filter2 = createFilter(Operand.IS, 'value', 'different'); + + expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false); + }); + }); + + describe('error cases', () => { + it('should throw error for unknown operand', () => { + const filter = createFilter('unknown' as Operand, 'value', 'value'); + + expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow( + 'Unknown operand: unknown', + ); + }); + }); + }); + + describe('multiple filters without groups', () => { + it('should apply AND logic by default for multiple filters', () => { + const filters: ResolvedFilter[] = [ + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'John', + }, + { + id: 'filter2', + type: 'number', + label: 'Age Filter', + rightOperand: 25, + operand: Operand.GT, + displayValue: '25', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group1', + leftOperand: 30, + }, + ]; + + const result = evaluateFilterConditions({ filters }); + + expect(result).toBe(true); + }); + + it('should return false when one filter fails in AND logic', () => { + const filters: ResolvedFilter[] = [ + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'John', + }, + { + id: 'filter2', + type: 'number', + label: 'Age Filter', + rightOperand: 25, + operand: Operand.GT, + displayValue: '25', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group1', + leftOperand: 20, // This will fail + }, + ]; + + const result = evaluateFilterConditions({ filters }); + + expect(result).toBe(false); + }); + }); + + describe('filter groups', () => { + describe('single group with AND logic', () => { + it('should return true when all filters pass', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.AND, + }, + ]; + + const filters: ResolvedFilter[] = [ + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'John', + }, + { + id: 'filter2', + type: 'number', + label: 'Age Filter', + rightOperand: 25, + operand: Operand.GT, + displayValue: '25', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group1', + leftOperand: 30, + }, + ]; + + const result = evaluateFilterConditions({ filterGroups, filters }); + + expect(result).toBe(true); + }); + + it('should return false when one filter fails', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.AND, + }, + ]; + + const filters: ResolvedFilter[] = [ + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'Jane', // This will fail + }, + { + id: 'filter2', + type: 'number', + label: 'Age Filter', + rightOperand: 25, + operand: Operand.GT, + displayValue: '25', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group1', + leftOperand: 30, + }, + ]; + + const result = evaluateFilterConditions({ filterGroups, filters }); + + expect(result).toBe(false); + }); + }); + + describe('single group with OR logic', () => { + it('should return true when at least one filter passes', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.OR, + }, + ]; + + const filters: ResolvedFilter[] = [ + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'Jane', // This will fail + }, + { + id: 'filter2', + type: 'number', + label: 'Age Filter', + rightOperand: 25, + operand: Operand.GT, + displayValue: '25', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group1', + leftOperand: 30, // This will pass + }, + ]; + + const result = evaluateFilterConditions({ filterGroups, filters }); + + expect(result).toBe(true); + }); + + it('should return false when all filters fail', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.OR, + }, + ]; + + const filters: ResolvedFilter[] = [ + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'Jane', // This will fail + }, + { + id: 'filter2', + type: 'number', + label: 'Age Filter', + rightOperand: 25, + operand: Operand.GT, + displayValue: '25', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group1', + leftOperand: 20, // This will fail + }, + ]; + + const result = evaluateFilterConditions({ filterGroups, filters }); + + expect(result).toBe(false); + }); + }); + + describe('empty groups', () => { + it('should return true for empty group', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.AND, + }, + ]; + + const result = evaluateFilterConditions({ filterGroups, filters: [] }); + + expect(result).toBe(true); + }); + }); + + describe('nested groups', () => { + it('should handle nested groups correctly', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.AND, + }, + { + id: 'group2', + logicalOperator: LogicalOperator.OR, + parentRecordFilterGroupId: 'group1', + positionInRecordFilterGroup: 1, + }, + ]; + + const filters: ResolvedFilter[] = [ + // Filter in parent group + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'John', // This will pass + }, + // Filters in child group with OR logic + { + id: 'filter2', + type: 'number', + label: 'Age Filter', + rightOperand: 25, + operand: Operand.GT, + displayValue: '25', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group2', + leftOperand: 20, // This will fail + }, + { + id: 'filter3', + type: 'text', + label: 'City Filter', + rightOperand: 'NYC', + operand: Operand.EQ, + displayValue: 'NYC', + fieldMetadataId: 'field3', + recordFilterGroupId: 'group2', + leftOperand: 'NYC', // This will pass + }, + ]; + + // Parent group uses AND: filter1 (pass) AND group2 (pass because filter3 passes) + const result = evaluateFilterConditions({ filterGroups, filters }); + + expect(result).toBe(true); + }); + + it('should handle multiple child groups with correct positioning', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.AND, + }, + { + id: 'group2', + logicalOperator: LogicalOperator.OR, + parentRecordFilterGroupId: 'group1', + positionInRecordFilterGroup: 1, + }, + { + id: 'group3', + logicalOperator: LogicalOperator.AND, + parentRecordFilterGroupId: 'group1', + positionInRecordFilterGroup: 2, + }, + ]; + + const filters: ResolvedFilter[] = [ + // Group2 filters (OR logic) + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group2', + leftOperand: 'Jane', // This will fail + }, + { + id: 'filter2', + type: 'text', + label: 'Status Filter', + rightOperand: 'active', + operand: Operand.EQ, + displayValue: 'active', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group2', + leftOperand: 'active', // This will pass + }, + // Group3 filters (AND logic) + { + id: 'filter3', + type: 'number', + label: 'Age Filter', + rightOperand: 18, + operand: Operand.GTE, + displayValue: '18', + fieldMetadataId: 'field3', + recordFilterGroupId: 'group3', + leftOperand: 25, // This will pass + }, + { + id: 'filter4', + type: 'number', + label: 'Score Filter', + rightOperand: 80, + operand: Operand.GT, + displayValue: '80', + fieldMetadataId: 'field4', + recordFilterGroupId: 'group3', + leftOperand: 85, // This will pass + }, + ]; + + // Group1 uses AND: group2 (pass) AND group3 (pass) + const result = evaluateFilterConditions({ filterGroups, filters }); + + expect(result).toBe(true); + }); + }); + + describe('multiple root groups', () => { + it('should combine multiple root groups with AND logic', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.OR, + }, + { + id: 'group2', + logicalOperator: LogicalOperator.AND, + }, + ]; + + const filters: ResolvedFilter[] = [ + // Group1 filters (OR logic) + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'John', // This will pass + }, + { + id: 'filter2', + type: 'text', + label: 'Status Filter', + rightOperand: 'inactive', + operand: Operand.EQ, + displayValue: 'inactive', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group1', + leftOperand: 'active', // This will fail + }, + // Group2 filters (AND logic) + { + id: 'filter3', + type: 'number', + label: 'Age Filter', + rightOperand: 18, + operand: Operand.GTE, + displayValue: '18', + fieldMetadataId: 'field3', + recordFilterGroupId: 'group2', + leftOperand: 25, // This will pass + }, + { + id: 'filter4', + type: 'number', + label: 'Score Filter', + rightOperand: 80, + operand: Operand.GT, + displayValue: '80', + fieldMetadataId: 'field4', + recordFilterGroupId: 'group2', + leftOperand: 85, // This will pass + }, + ]; + + // Root groups combined with AND: group1 (pass) AND group2 (pass) + const result = evaluateFilterConditions({ filterGroups, filters }); + + expect(result).toBe(true); + }); + + it('should return false when one root group fails', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.OR, + }, + { + id: 'group2', + logicalOperator: LogicalOperator.AND, + }, + ]; + + const filters: ResolvedFilter[] = [ + // Group1 filters (OR logic) - will pass + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'John', // This will pass + }, + // Group2 filters (AND logic) - will fail + { + id: 'filter2', + type: 'number', + label: 'Age Filter', + rightOperand: 30, + operand: Operand.GT, + displayValue: '30', + fieldMetadataId: 'field2', + recordFilterGroupId: 'group2', + leftOperand: 25, // This will fail + }, + { + id: 'filter3', + type: 'number', + label: 'Score Filter', + rightOperand: 80, + operand: Operand.GT, + displayValue: '80', + fieldMetadataId: 'field3', + recordFilterGroupId: 'group2', + leftOperand: 85, // This will pass + }, + ]; + + // Root groups combined with AND: group1 (pass) AND group2 (fail) + const result = evaluateFilterConditions({ filterGroups, filters }); + + expect(result).toBe(false); + }); + }); + + describe('error cases', () => { + it('should throw error when filter group is not found', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: LogicalOperator.AND, + }, + ]; + + const filters: ResolvedFilter[] = [ + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'nonexistent-group', + leftOperand: 'John', + }, + ]; + + expect(() => + evaluateFilterConditions({ filterGroups, filters }), + ).toThrow('Filter group with id nonexistent-group not found'); + }); + + it('should throw error for unknown logical operator', () => { + const filterGroups: FilterGroup[] = [ + { + id: 'group1', + logicalOperator: 'UNKNOWN' as LogicalOperator, + }, + ]; + + const filters: ResolvedFilter[] = [ + { + id: 'filter1', + type: 'text', + label: 'Name Filter', + rightOperand: 'John', + operand: Operand.EQ, + displayValue: 'John', + fieldMetadataId: 'field1', + recordFilterGroupId: 'group1', + leftOperand: 'John', + }, + ]; + + expect(() => + evaluateFilterConditions({ filterGroups, filters }), + ).toThrow('Unknown logical operator: UNKNOWN'); + }); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/get-previous-step-output.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/get-previous-step-output.util.spec.ts deleted file mode 100644 index d6e950eec..000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/get-previous-step-output.util.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { WorkflowStepExecutorException } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; -import { getPreviousStepOutput } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/get-previous-step-output.util'; -import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; - -describe('getPreviousStepOutput', () => { - const mockSteps: WorkflowAction[] = [ - { - id: 'step1', - nextStepIds: ['step2'], - } as WorkflowAction, - { - id: 'step2', - nextStepIds: ['step3'], - } as WorkflowAction, - { - id: 'step3', - nextStepIds: undefined, - } as WorkflowAction, - ]; - - const mockContext = { - step1: { data: 'step1 output' }, - step2: { data: 'step2 output' }, - }; - - it('should return the previous step output when valid', () => { - const result = getPreviousStepOutput(mockSteps, 'step2', mockContext); - - expect(result).toEqual({ data: 'step1 output' }); - }); - - it('should throw an error when there is no previous step', () => { - expect(() => - getPreviousStepOutput(mockSteps, 'step1', mockContext), - ).toThrow(WorkflowStepExecutorException); - }); - - it('should throw an error when there are multiple previous steps', () => { - const stepsWithMultiplePrevious: WorkflowAction[] = [ - { - id: 'step1', - nextStepIds: ['step3'], - } as WorkflowAction, - { - id: 'step2', - nextStepIds: ['step3'], - } as WorkflowAction, - { - id: 'step3', - nextStepIds: undefined, - } as WorkflowAction, - ]; - - expect(() => - getPreviousStepOutput(stepsWithMultiplePrevious, 'step3', mockContext), - ).toThrow(WorkflowStepExecutorException); - }); - - it('should throw an error when previous step output is not found', () => { - const emptyContext = {}; - - expect(() => - getPreviousStepOutput(mockSteps, 'step2', emptyContext), - ).toThrow(WorkflowStepExecutorException); - }); -}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util.ts deleted file mode 100644 index 4b4bc7851..000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { FilterCondition } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-condition.type'; -import { FilterOperator } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-operator.type'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const applyFilter = >( - array: T[], - filter: FilterCondition, -): T[] => { - return array.filter((item) => evaluateFilter(item, filter)); -}; - -const evaluateFilter = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - item: Record, - filter: FilterCondition, -): boolean => { - if ('and' in filter) { - return ( - filter.and?.every((subFilter) => evaluateFilter(item, subFilter)) ?? false - ); - } - - if ('or' in filter) { - return ( - filter.or?.some((subFilter) => evaluateFilter(item, subFilter)) ?? false - ); - } - - if ('not' in filter && filter.not) { - return !evaluateFilter(item, filter.not); - } - - return Object.entries(filter).every(([field, conditions]) => { - const value = item[field]; - - if (isOperator(conditions)) { - return evaluateCondition(value, conditions as FilterOperator); - } - - if (value === undefined && !hasNullCheck(conditions as FilterCondition)) { - return false; - } - - return evaluateNestedConditions(value, conditions as FilterCondition); - }); -}; - -const hasNullCheck = (conditions: FilterCondition): boolean => { - const operator = conditions as FilterOperator; - - if ('is' in operator && operator.is === 'NULL') { - return true; - } - - return Object.values(conditions).some( - (value) => - typeof value === 'object' && - value !== null && - hasNullCheck(value as FilterCondition), - ); -}; - -const evaluateNestedConditions = ( - value: unknown, - conditions: FilterCondition, -): boolean => { - const operator = conditions as FilterOperator; - - if ('is' in operator && operator.is === 'NULL') { - return value === null || value === undefined; - } - - if (value === null || value === undefined) { - return false; - } - - return Object.entries(conditions).every(([field, nestedConditions]) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nestedValue = (value as Record)[field]; - - if (isOperator(nestedConditions)) { - return evaluateCondition(nestedValue, nestedConditions as FilterOperator); - } - - return evaluateNestedConditions( - nestedValue, - nestedConditions as FilterCondition, - ); - }); -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const evaluateCondition = (value: any, condition: FilterOperator): boolean => { - const [[operator, targetValue]] = Object.entries(condition); - - switch (operator) { - case 'eq': - return value === targetValue; - - case 'ne': - return value !== targetValue; - - case 'gt': - return value > targetValue; - - case 'gte': - return value >= targetValue; - - case 'lt': - return value < targetValue; - - case 'lte': - return value <= targetValue; - - case 'like': - return matchesLike(value, targetValue as string); - - case 'ilike': - return matchesILike(value, targetValue as string); - - case 'in': - if (!Array.isArray(targetValue)) return false; - if (Array.isArray(value)) { - return value.some((v) => targetValue.includes(v)); - } - - return targetValue.includes(value); - - case 'is': - return targetValue === 'NULL' - ? value === null || value === undefined - : value === targetValue; - - default: - return false; - } -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const matchesLike = (value: any, pattern: string): boolean => { - if (typeof value !== 'string') { - return false; - } - - const regexPattern = pattern.replace(/%/g, '.*').replace(/_/g, '.'); - - return new RegExp(`^${regexPattern}$`).test(value); -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const matchesILike = (value: any, pattern: string): boolean => { - if (typeof value !== 'string') { - return false; - } - - const regexPattern = pattern.replace(/%/g, '.*').replace(/_/g, '.'); - - return new RegExp(`^${regexPattern}$`, 'i').test(value); -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const isOperator = (obj: any): boolean => { - if (typeof obj !== 'object' || obj === null) { - return false; - } - - return [ - 'eq', - 'ne', - 'gt', - 'gte', - 'lt', - 'lte', - 'like', - 'ilike', - 'in', - 'is', - ].some((op) => op in obj); -}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts new file mode 100644 index 000000000..5597d138f --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts @@ -0,0 +1,155 @@ +import { + Filter, + FilterGroup, +} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type'; + +type ResolvedFilter = Omit & { + rightOperand: unknown; + leftOperand: unknown; +}; + +function evaluateFilter(filter: ResolvedFilter): boolean { + const leftValue = filter.leftOperand; + const rightValue = filter.rightOperand; + + switch (filter.operand) { + case 'eq': + return leftValue == rightValue; + + case 'ne': + return leftValue != rightValue; + + case 'gt': + return Number(leftValue) > Number(rightValue); + + case 'gte': + return Number(leftValue) >= Number(rightValue); + + case 'lt': + return Number(leftValue) < Number(rightValue); + + case 'lte': + return Number(leftValue) <= Number(rightValue); + + case 'like': + return String(leftValue).includes(String(rightValue)); + + case 'ilike': + return String(leftValue) + .toLowerCase() + .includes(String(rightValue).toLowerCase()); + + case 'in': + try { + const values = JSON.parse(String(rightValue)); + + return Array.isArray(values) && values.includes(leftValue); + } catch { + const values = String(rightValue) + .split(',') + .map((v) => v.trim()); + + return values.includes(String(leftValue)); + } + + case 'is': + if (String(rightValue).toLowerCase() === 'null') { + return leftValue === null || leftValue === undefined; + } + if (String(rightValue).toLowerCase() === 'not null') { + return leftValue !== null && leftValue !== undefined; + } + + return leftValue === rightValue; + + default: + throw new Error(`Unknown operand: ${filter.operand}`); + } +} + +/** + * Recursively evaluates a filter group and its children + */ +function evaluateFilterGroup( + groupId: string, + filterGroups: FilterGroup[], + filters: ResolvedFilter[], +): boolean { + const group = filterGroups.find((g) => g.id === groupId); + + if (!group) { + throw new Error(`Filter group with id ${groupId} not found`); + } + + // Get all direct child groups + const childGroups = filterGroups + .filter((g) => g.parentRecordFilterGroupId === groupId) + .sort( + (a, b) => + (a.positionInRecordFilterGroup || 0) - + (b.positionInRecordFilterGroup || 0), + ); + + const groupFilters = filters.filter((f) => f.recordFilterGroupId === groupId); + + const filterResults = groupFilters.map((filter) => evaluateFilter(filter)); + + const childGroupResults = childGroups.map((childGroup) => + evaluateFilterGroup(childGroup.id, filterGroups, filters), + ); + + const allResults = [...filterResults, ...childGroupResults]; + + if (allResults.length === 0) { + return true; + } + + switch (group.logicalOperator) { + case 'AND': + return allResults.every((result) => result); + + case 'OR': + return allResults.some((result) => result); + + default: + throw new Error(`Unknown logical operator: ${group.logicalOperator}`); + } +} + +export function evaluateFilterConditions({ + filterGroups = [], + filters = [], +}: { + filterGroups?: FilterGroup[]; + filters?: ResolvedFilter[]; +}): boolean { + if (filterGroups.length === 0 && filters.length === 0) { + return true; + } + + if (filterGroups.length > 0) { + const groupIds = new Set(filterGroups.map((g) => g.id)); + + for (const filter of filters) { + if (!groupIds.has(filter.recordFilterGroupId)) { + throw new Error( + `Filter group with id ${filter.recordFilterGroupId} not found`, + ); + } + } + } + + const rootGroups = filterGroups.filter((g) => !g.parentRecordFilterGroupId); + + if (rootGroups.length === 0 && filters.length > 0) { + const filterResults = filters.map((filter) => evaluateFilter(filter)); + + return filterResults.every((result) => result); + } + + const rootResults = rootGroups.map((rootGroup) => + evaluateFilterGroup(rootGroup.id, filterGroups, filters), + ); + + return rootResults.every((result) => result); +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/get-previous-step-output.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/get-previous-step-output.util.ts deleted file mode 100644 index 96c1ed9a8..000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/get-previous-step-output.util.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - WorkflowStepExecutorException, - WorkflowStepExecutorExceptionCode, -} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; -import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; - -export const getPreviousStepOutput = ( - steps: WorkflowAction[], - currentStepId: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: Record, -) => { - const previousSteps = steps.filter((step) => - step?.nextStepIds?.includes(currentStepId), - ); - - if (previousSteps.length === 0) { - throw new WorkflowStepExecutorException( - 'Filter action must have a previous step', - WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP, - ); - } - - if (previousSteps.length > 1) { - throw new WorkflowStepExecutorException( - 'Filter action must have only one previous step', - WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP, - ); - } - - const previousStep = previousSteps[0]; - const previousStepOutput = context[previousStep.id]; - - if (!previousStepOutput) { - throw new WorkflowStepExecutorException( - 'Previous step output not found', - WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP, - ); - } - - return previousStepOutput; -}; diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-many-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-many-object-records-permissions.integration-spec.ts index ada6dbbdb..2811493b6 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-many-object-records-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-many-object-records-permissions.integration-spec.ts @@ -69,8 +69,16 @@ describe('deleteManyObjectRecordsPermissions', () => { expect(response.body.data).toBeDefined(); expect(response.body.data.deletePeople).toBeDefined(); expect(response.body.data.deletePeople).toHaveLength(2); - expect(response.body.data.deletePeople[0].id).toBe(personId1); - expect(response.body.data.deletePeople[1].id).toBe(personId2); + expect( + response.body.data.deletePeople.some( + (person: { id: string }) => person.id === personId1, + ), + ).toBe(true); + expect( + response.body.data.deletePeople.some( + (person: { id: string }) => person.id === personId2, + ), + ).toBe(true); }); it('should delete multiple object records when executed by api key', async () => { diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-many-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-many-object-records-permissions.integration-spec.ts index 089b7403d..ce7aa7df2 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-many-object-records-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-many-object-records-permissions.integration-spec.ts @@ -68,7 +68,15 @@ describe('destroyManyObjectRecordsPermissions', () => { expect(response.body.data).toBeDefined(); expect(response.body.data.destroyPeople).toBeDefined(); expect(response.body.data.destroyPeople).toHaveLength(2); - expect(response.body.data.destroyPeople[0].id).toBe(personId1); - expect(response.body.data.destroyPeople[1].id).toBe(personId2); + expect( + response.body.data.destroyPeople.some( + (person: { id: string }) => person.id === personId1, + ), + ).toBe(true); + expect( + response.body.data.destroyPeople.some( + (person: { id: string }) => person.id === personId2, + ), + ).toBe(true); }); });