diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception.ts index 82aefc7ff..4a5073b92 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception.ts @@ -10,4 +10,7 @@ export enum WorkflowStepExecutorExceptionCode { SCOPED_WORKSPACE_NOT_FOUND = 'SCOPED_WORKSPACE_NOT_FOUND', INVALID_STEP_TYPE = 'INVALID_STEP_TYPE', STEP_NOT_FOUND = 'STEP_NOT_FOUND', + INVALID_STEP_SETTINGS = 'INVALID_STEP_SETTINGS', + INTERNAL_ERROR = 'INTERNAL_ERROR', + FAILED_TO_EXECUTE_STEP = 'FAILED_TO_EXECUTE_STEP', } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-executor.factory.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-executor.factory.ts index b7acabfe4..56975e0f6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-executor.factory.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-executor.factory.ts @@ -7,6 +7,7 @@ import { WorkflowStepExecutorExceptionCode, } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; import { CodeWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action'; +import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action'; import { FormWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form.workflow-action'; import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action'; import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action'; @@ -25,6 +26,7 @@ export class WorkflowExecutorFactory { private readonly deleteRecordWorkflowAction: DeleteRecordWorkflowAction, private readonly findRecordsWorkflowAction: FindRecordsWorkflowAction, private readonly formWorkflowAction: FormWorkflowAction, + private readonly filterWorkflowAction: FilterWorkflowAction, ) {} get(stepType: WorkflowActionType): WorkflowExecutor { @@ -43,6 +45,8 @@ export class WorkflowExecutorFactory { return this.findRecordsWorkflowAction; case WorkflowActionType.FORM: return this.formWorkflowAction; + case WorkflowActionType.FILTER: + return this.filterWorkflowAction; default: throw new WorkflowStepExecutorException( `Workflow step executor not found for step type '${stepType}'`, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module.ts new file mode 100644 index 000000000..562471a3f --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action'; + +@Module({ + providers: [FilterWorkflowAction], + exports: [FilterWorkflowAction], +}) +export class FilterActionModule {} 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 new file mode 100644 index 000000000..13bb1090c --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface'; + +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} 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 { 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'; + +@Injectable() +export class FilterWorkflowAction implements WorkflowExecutor { + async execute(input: WorkflowExecutorInput): Promise { + const { currentStepId, steps, context } = input; + + const step = steps.find((step) => step.id === currentStepId); + + if (!step) { + throw new WorkflowStepExecutorException( + 'Step not found', + WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND, + ); + } + + if (!isWorkflowFilterAction(step)) { + throw new WorkflowStepExecutorException( + 'Step is not a filter action', + WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE, + ); + } + + const { filter } = step.settings.input; + + if (!filter) { + throw new WorkflowStepExecutorException( + 'Filter is not defined', + WorkflowStepExecutorExceptionCode.INVALID_STEP_SETTINGS, + ); + } + + const previousStepOutput = getPreviousStepOutput( + steps, + currentStepId, + context, + ); + + const isPreviousStepOutputArray = Array.isArray(previousStepOutput); + + const previousStepOutputArray = isPreviousStepOutputArray + ? previousStepOutput + : [previousStepOutput]; + + const filteredOutput = applyFilter(previousStepOutputArray, filter); + + if (filteredOutput.length === 0) { + return { + result: undefined, + }; + } + + return { + result: isPreviousStepOutputArray ? filteredOutput : filteredOutput[0], + }; + } +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard.ts new file mode 100644 index 000000000..9025a4e5d --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard.ts @@ -0,0 +1,9 @@ +import { + WorkflowAction, + WorkflowActionType, + WorkflowFilterAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const isWorkflowFilterAction = ( + action: WorkflowAction, +): action is WorkflowFilterAction => action.type === WorkflowActionType.FILTER; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-condition.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-condition.type.ts new file mode 100644 index 000000000..30749edac --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-condition.type.ts @@ -0,0 +1,12 @@ +import { FilterOperator } from './filter-operator.type'; + +export type FilterCondition = { + and?: FilterCondition[]; + or?: FilterCondition[]; + not?: FilterCondition; + [key: string]: + | FilterOperator + | FilterCondition + | FilterCondition[] + | undefined; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-operator.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-operator.type.ts new file mode 100644 index 000000000..c9ab66ddf --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/filter-operator.type.ts @@ -0,0 +1,12 @@ +export type FilterOperator = { + eq?: any; + ne?: any; + gt?: number; + gte?: number; + lt?: number; + lte?: number; + like?: string; + ilike?: string; + in?: any[]; + is?: 'NULL' | any; +}; 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 new file mode 100644 index 000000000..8674d85f1 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type.ts @@ -0,0 +1,9 @@ +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 type WorkflowFilterActionSettings = BaseWorkflowActionSettings & { + input: { + filter: Partial; + }; +}; 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 new file mode 100644 index 000000000..227526ec9 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/apply-filter.util.spec.ts @@ -0,0 +1,324 @@ +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__/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 new file mode 100644 index 000000000..d6e950eec --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/get-previous-step-output.util.spec.ts @@ -0,0 +1,66 @@ +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 new file mode 100644 index 000000000..452952abf --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/apply-filter.util.ts @@ -0,0 +1,172 @@ +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'; + +export const applyFilter = >( + array: T[], + filter: FilterCondition, +): T[] => { + return array.filter((item) => evaluateFilter(item, filter)); +}; + +const evaluateFilter = ( + 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]) => { + const nestedValue = (value as Record)[field]; + + if (isOperator(nestedConditions)) { + return evaluateCondition(nestedValue, nestedConditions as FilterOperator); + } + + return evaluateNestedConditions( + nestedValue, + nestedConditions as FilterCondition, + ); + }); +}; + +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; + } +}; + +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); +}; + +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); +}; + +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/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 new file mode 100644 index 000000000..4675ebb16 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/get-previous-step-output.util.ts @@ -0,0 +1,41 @@ +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, + 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/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 e24d634a0..d14bab433 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 @@ -54,6 +54,7 @@ export class CreateRecordWorkflowAction implements WorkflowExecutor { WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND, ); } + if (!isWorkflowCreateRecordAction(step)) { throw new WorkflowStepExecutorException( 'Step is not a create record action', diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts index 81832319a..10e200170 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts @@ -1,5 +1,6 @@ import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; import { WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type'; +import { WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type'; import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type'; import { WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type'; import { @@ -28,4 +29,5 @@ export type WorkflowActionSettings = | WorkflowUpdateRecordActionSettings | WorkflowDeleteRecordActionSettings | WorkflowFindRecordsActionSettings - | WorkflowFormActionSettings; + | WorkflowFormActionSettings + | WorkflowFilterActionSettings; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts index 14bfa14cb..d7da112f5 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts @@ -1,4 +1,5 @@ import { WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type'; +import { WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type'; import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type'; import { WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type'; import { @@ -17,6 +18,7 @@ export enum WorkflowActionType { DELETE_RECORD = 'DELETE_RECORD', FIND_RECORDS = 'FIND_RECORDS', FORM = 'FORM', + FILTER = 'FILTER', } type BaseWorkflowAction = { @@ -63,6 +65,11 @@ export type WorkflowFormAction = BaseWorkflowAction & { settings: WorkflowFormActionSettings; }; +export type WorkflowFilterAction = BaseWorkflowAction & { + type: WorkflowActionType.FILTER; + settings: WorkflowFilterActionSettings; +}; + export type WorkflowAction = | WorkflowCodeAction | WorkflowSendEmailAction @@ -70,4 +77,5 @@ export type WorkflowAction = | WorkflowUpdateRecordAction | WorkflowDeleteRecordAction | WorkflowFindRecordsAction - | WorkflowFormAction; + | WorkflowFormAction + | WorkflowFilterAction; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts index 462dd2805..e5da10706 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts @@ -5,6 +5,7 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory'; import { CodeActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module'; +import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module'; import { FormActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form-action.module'; import { SendEmailActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email-action.module'; import { RecordCRUDActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module'; @@ -20,6 +21,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow FormActionModule, WorkflowRunModule, BillingModule, + FilterActionModule, ], providers: [ WorkflowExecutorWorkspaceService,