diff --git a/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/computeCursorArgFilter.test.ts b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/computeCursorArgFilter.test.ts new file mode 100644 index 00000000000..4fefce88b9b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/computeCursorArgFilter.test.ts @@ -0,0 +1,180 @@ +import { computeCursorArgFilter } from '@/object-record/graphql/utils/computeCursorArgFilter'; +import { type RecordGqlOperationOrderBy } from 'twenty-shared/types'; + +describe('computeCursorArgFilter', () => { + it('should append an id tie-breaker when ordering does not include id', () => { + const orderBy: RecordGqlOperationOrderBy = [{ createdAt: 'AscNullsFirst' }]; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { createdAt: '2024-01-01', id: 'record-1' }, + isForwardPagination: true, + }); + + expect(result).toEqual({ + or: [ + { createdAt: { gt: '2024-01-01' } }, + { + and: [ + { createdAt: { eq: '2024-01-01' } }, + { id: { gt: 'record-1' } }, + ], + }, + ], + }); + }); + + it('should not append an id field when it is already part of the ordering', () => { + const orderBy: RecordGqlOperationOrderBy = [{ id: 'AscNullsFirst' }]; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { id: 'record-1' }, + isForwardPagination: true, + }); + + expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] }); + }); + + it('should use lt operator for ascending order with backward pagination', () => { + const orderBy: RecordGqlOperationOrderBy = [{ id: 'AscNullsLast' }]; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { id: 'record-1' }, + isForwardPagination: false, + }); + + expect(result).toEqual({ or: [{ id: { lt: 'record-1' } }] }); + }); + + it('should use lt operator for descending order with forward pagination', () => { + const orderBy: RecordGqlOperationOrderBy = [{ id: 'DescNullsFirst' }]; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { id: 'record-1' }, + isForwardPagination: true, + }); + + expect(result).toEqual({ or: [{ id: { lt: 'record-1' } }] }); + }); + + it('should use gt operator for descending order with backward pagination', () => { + const orderBy: RecordGqlOperationOrderBy = [{ id: 'DescNullsLast' }]; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { id: 'record-1' }, + isForwardPagination: false, + }); + + expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] }); + }); + + it('should resolve nested composite sub-fields and read their cursor value', () => { + const orderBy: RecordGqlOperationOrderBy = [ + { name: { firstName: 'AscNullsFirst' } }, + ]; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { name: { firstName: 'John' }, id: 'record-1' }, + isForwardPagination: true, + }); + + expect(result).toEqual({ + or: [ + { name: { firstName: { gt: 'John' } } }, + { + and: [ + { name: { firstName: { eq: 'John' } } }, + { id: { gt: 'record-1' } }, + ], + }, + ], + }); + }); + + it('should fall back to undefined cursor value for missing composite parent', () => { + const orderBy: RecordGqlOperationOrderBy = [ + { name: { firstName: 'AscNullsFirst' } }, + ]; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { id: 'record-1' }, + isForwardPagination: true, + }); + + expect(result).toEqual({ + or: [ + { name: { firstName: { gt: undefined } } }, + { + and: [ + { name: { firstName: { eq: undefined } } }, + { id: { gt: 'record-1' } }, + ], + }, + ], + }); + }); + + it('should ignore nested values that are not order-by directions', () => { + const orderBy = [ + { name: { firstName: 'AscNullsFirst', metadata: 'not-a-direction' } }, + ] as unknown as RecordGqlOperationOrderBy; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { name: { firstName: 'John' }, id: 'record-1' }, + isForwardPagination: true, + }); + + expect(result).toEqual({ + or: [ + { name: { firstName: { gt: 'John' } } }, + { + and: [ + { name: { firstName: { eq: 'John' } } }, + { id: { gt: 'record-1' } }, + ], + }, + ], + }); + }); + + it('should build cumulative equality prefixes across multiple fields', () => { + const orderBy: RecordGqlOperationOrderBy = [ + { score: 'DescNullsLast' }, + { id: 'AscNullsFirst' }, + ]; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { score: 42, id: 'record-1' }, + isForwardPagination: true, + }); + + expect(result).toEqual({ + or: [ + { score: { lt: 42 } }, + { + and: [{ score: { eq: 42 } }, { id: { gt: 'record-1' } }], + }, + ], + }); + }); + + it('should fall back to the id tie-breaker when there are no order-by fields', () => { + const orderBy = [{}] as unknown as RecordGqlOperationOrderBy; + + const result = computeCursorArgFilter({ + orderBy, + cursorRecordValues: { id: 'record-1' }, + isForwardPagination: true, + }); + + expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/extractOrderByFieldNames.test.ts b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/extractOrderByFieldNames.test.ts new file mode 100644 index 00000000000..5d3cc1d1168 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/extractOrderByFieldNames.test.ts @@ -0,0 +1,43 @@ +import { extractOrderByFieldNames } from '@/object-record/graphql/utils/extractOrderByFieldNames'; +import { type RecordGqlOperationOrderBy } from 'twenty-shared/types'; + +describe('extractOrderByFieldNames', () => { + it('should always include the id field', () => { + expect(extractOrderByFieldNames([])).toEqual({ id: true }); + }); + + it('should extract top-level field names', () => { + const orderBy: RecordGqlOperationOrderBy = [ + { createdAt: 'AscNullsFirst' }, + { name: 'DescNullsLast' }, + ]; + + expect(extractOrderByFieldNames(orderBy)).toEqual({ + id: true, + createdAt: true, + name: true, + }); + }); + + it('should extract nested composite sub-field names', () => { + const orderBy: RecordGqlOperationOrderBy = [ + { name: { firstName: 'AscNullsFirst', lastName: 'DescNullsLast' } }, + ]; + + expect(extractOrderByFieldNames(orderBy)).toEqual({ + id: true, + name: { firstName: true, lastName: true }, + }); + }); + + it('should ignore nested values that are not directions', () => { + const orderBy = [ + { name: { firstName: 'AscNullsFirst', meta: 'Unknown' } }, + ] as unknown as RecordGqlOperationOrderBy; + + expect(extractOrderByFieldNames(orderBy)).toEqual({ + id: true, + name: { firstName: true }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/isOrderByDirection.test.ts b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/isOrderByDirection.test.ts new file mode 100644 index 00000000000..ae78a9ca5ed --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/isOrderByDirection.test.ts @@ -0,0 +1,23 @@ +import { isOrderByDirection } from '@/object-record/graphql/utils/isOrderByDirection'; + +describe('isOrderByDirection', () => { + it('should return true for valid order by directions', () => { + expect(isOrderByDirection('AscNullsFirst')).toBe(true); + expect(isOrderByDirection('AscNullsLast')).toBe(true); + expect(isOrderByDirection('DescNullsFirst')).toBe(true); + expect(isOrderByDirection('DescNullsLast')).toBe(true); + }); + + it('should return false for unknown strings', () => { + expect(isOrderByDirection('Asc')).toBe(false); + expect(isOrderByDirection('random')).toBe(false); + expect(isOrderByDirection('')).toBe(false); + }); + + it('should return false for non-string values', () => { + expect(isOrderByDirection(undefined)).toBe(false); + expect(isOrderByDirection(null)).toBe(false); + expect(isOrderByDirection(42)).toBe(false); + expect(isOrderByDirection({ foo: 'AscNullsFirst' })).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/reverseOrderBy.test.ts b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/reverseOrderBy.test.ts new file mode 100644 index 00000000000..abce9ed3958 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/reverseOrderBy.test.ts @@ -0,0 +1,50 @@ +import { reverseOrderBy } from '@/object-record/graphql/utils/reverseOrderBy'; +import { type RecordGqlOperationOrderBy } from 'twenty-shared/types'; + +describe('reverseOrderBy', () => { + it('should reverse top-level directions', () => { + const orderBy: RecordGqlOperationOrderBy = [ + { createdAt: 'AscNullsFirst' }, + { name: 'DescNullsLast' }, + ]; + + expect(reverseOrderBy(orderBy)).toEqual([ + { createdAt: 'DescNullsLast' }, + { name: 'AscNullsFirst' }, + ]); + }); + + it('should reverse all direction variants', () => { + const orderBy: RecordGqlOperationOrderBy = [ + { a: 'AscNullsFirst' }, + { b: 'AscNullsLast' }, + { c: 'DescNullsFirst' }, + { d: 'DescNullsLast' }, + ]; + + expect(reverseOrderBy(orderBy)).toEqual([ + { a: 'DescNullsLast' }, + { b: 'DescNullsFirst' }, + { c: 'AscNullsLast' }, + { d: 'AscNullsFirst' }, + ]); + }); + + it('should reverse nested composite field directions', () => { + const orderBy: RecordGqlOperationOrderBy = [ + { name: { firstName: 'AscNullsFirst', lastName: 'DescNullsLast' } }, + ]; + + expect(reverseOrderBy(orderBy)).toEqual([ + { name: { firstName: 'DescNullsLast', lastName: 'AscNullsFirst' } }, + ]); + }); + + it('should leave unknown values untouched', () => { + const orderBy = [ + { name: 'Unknown' }, + ] as unknown as RecordGqlOperationOrderBy; + + expect(reverseOrderBy(orderBy)).toEqual([{ name: 'Unknown' }]); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts index 59c84050e90..697d96e973c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts @@ -4,15 +4,10 @@ import { useWorkflowVersionIdOrThrow } from '@/workflow/hooks/useWorkflowVersion import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector'; import { searchVariableThroughOutputSchemaV2 } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2'; import { isDefined } from 'twenty-shared/utils'; -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; - -export type VariableSearchResult = { - variableLabel: string | undefined; - variablePathLabel: string | undefined; - variableType?: string; - fieldMetadataId?: string; - compositeFieldSubFieldName?: string; -}; +import { + TRIGGER_STEP_ID, + type VariableSearchResult, +} from 'twenty-shared/workflow'; export const useSearchVariable = ({ stepId, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughOutputSchemaV2.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughOutputSchemaV2.test.ts new file mode 100644 index 00000000000..03899966e84 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughOutputSchemaV2.test.ts @@ -0,0 +1,45 @@ +import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; +import { searchVariableThroughOutputSchemaV2 } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2'; + +describe('searchVariableThroughOutputSchemaV2', () => { + const stepOutputSchema: StepOutputSchemaV2 = { + id: 'step-1', + name: 'HTTP Request', + type: 'CODE', + outputSchema: { + message: { + isLeaf: true, + type: 'string', + label: 'Message', + value: 'Hello World', + }, + }, + }; + + it('should resolve a variable through the shared dispatcher', () => { + const result = searchVariableThroughOutputSchemaV2({ + stepOutputSchema, + stepType: 'CODE', + rawVariableName: '{{step-1.message}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Message', + variablePathLabel: 'HTTP Request > Message', + variableType: 'string', + }); + }); + + it('should return an empty result for an unknown variable path', () => { + const result = searchVariableThroughOutputSchemaV2({ + stepOutputSchema, + stepType: 'CODE', + rawVariableName: '{{step-1.unknownField}}', + isFullRecord: false, + }); + + expect(result.variableLabel).toBeUndefined(); + expect(result.variablePathLabel).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts deleted file mode 100644 index 8918f0ebcd1..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; -import { isDefined } from 'twenty-shared/utils'; -import { - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - parseVariablePath, - type BaseOutputSchemaV2, -} from 'twenty-shared/workflow'; - -/** - * Parses a variable name to extract its components - * Example: "{{step1.field.value}}" -> { stepId: "step1", pathSegments: ["field"], targetFieldName: "value" } - */ -const parseVariableName = (rawVariableName: string) => { - const variableWithoutBrackets = rawVariableName.replace( - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - (_, variableName) => variableName, - ); - - const parts = parseVariablePath(variableWithoutBrackets); - const stepId = parts.at(0); - - return { - stepId, - targetFieldName: parts.at(-1), - pathSegments: parts.slice(1, -1), - }; -}; - -const navigateToTargetField = ( - startingSchema: BaseOutputSchemaV2, - pathSegments: string[], -): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => { - let currentSchema: BaseOutputSchemaV2 = startingSchema; - const pathLabels: string[] = []; - - for (const pathSegment of pathSegments) { - const field = currentSchema[pathSegment]; - - if (!isDefined(field) || field.isLeaf === true) { - return null; - } - - pathLabels.push(field.label); - currentSchema = field.value; - } - - return { schema: currentSchema, pathLabels }; -}; - -const buildVariableResult = ( - stepName: string, - pathLabels: string[], - targetSchema: BaseOutputSchemaV2, - targetFieldName: string, -): VariableSearchResult => { - const targetField = targetSchema[targetFieldName]; - - if (!isDefined(targetField)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - // Build the full path: stepName > field1 > field2 > targetField - const fullPathSegments = [stepName, ...pathLabels, targetField.label]; - const variablePathLabel = fullPathSegments.join(' > '); - - return { - variableLabel: targetField.label, - variablePathLabel, - variableType: targetField.type, - }; -}; - -export const searchBaseOutputSchema = ({ - stepName, - baseOutputSchema, - path, - selectedField, -}: { - stepName: string; - baseOutputSchema: BaseOutputSchemaV2; - path: string[]; - selectedField: string; -}): VariableSearchResult => { - const navigationResult = navigateToTargetField(baseOutputSchema, path); - - if (!navigationResult) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - variableType: undefined, - }; - } - - return buildVariableResult( - stepName, - navigationResult.pathLabels, - navigationResult.schema, - selectedField, - ); -}; - -/** - * Searches for a variable within a base output schema and returns its metadata - * - * @param stepName - Display name of the workflow step - * @param baseOutputSchema - The base schema to search within - * @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.object.nested.value" - * @param isFullRecord - Whether to return info for the entire record vs specific field (not used for base schema) - * @returns Variable metadata including labels, types, and field information - */ -export const searchVariableThroughBaseOutputSchema = ({ - stepName, - baseOutputSchema, - rawVariableName, -}: { - stepName: string; - baseOutputSchema: BaseOutputSchemaV2; - rawVariableName: string; -}): VariableSearchResult => { - if (!isDefined(baseOutputSchema)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - const { stepId, pathSegments, targetFieldName } = - parseVariableName(rawVariableName); - - if (!isDefined(stepId) || !isDefined(targetFieldName)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - return searchBaseOutputSchema({ - stepName, - baseOutputSchema, - path: pathSegments, - selectedField: targetFieldName, - }); -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema.ts deleted file mode 100644 index 0ee9788d9e7..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; -import type { CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema'; -import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema'; -import { isDefined } from 'twenty-shared/utils'; - -const isLinkOutputSchema = ( - codeOutputSchema: CodeOutputSchema, -): codeOutputSchema is { link: any; _outputSchemaType: 'LINK' } => { - return ( - isDefined(codeOutputSchema) && codeOutputSchema._outputSchemaType === 'LINK' - ); -}; - -/** - * Searches for a variable within a code output schema and returns its metadata - * - * @param stepName - Display name of the workflow step - * @param codeOutputSchema - The code schema to search within - * @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.link" - * @param isFullRecord - Whether to return info for the entire record vs specific field - * @returns Variable metadata including labels, types, and field information - */ -export const searchVariableThroughCodeOutputSchema = ({ - stepName, - codeOutputSchema, - rawVariableName, -}: { - stepName: string; - codeOutputSchema: CodeOutputSchema; - rawVariableName: string; -}): VariableSearchResult => { - if (!isDefined(codeOutputSchema) || isLinkOutputSchema(codeOutputSchema)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - return searchVariableThroughBaseOutputSchema({ - stepName, - baseOutputSchema: codeOutputSchema, - rawVariableName, - }); -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts deleted file mode 100644 index 5047f2b5a9f..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; -import type { FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema'; -import { searchRecordOutputSchema as searchRecordOutputSchemaUtil } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - parseVariablePath, -} from 'twenty-shared/workflow'; - -type SearchResultKey = 'first' | 'all' | 'totalCount'; - -/** - * Parses a variable name to extract its components for SearchRecord outputs - * Example: "{{step1.first.user.name}}" -> { stepId: "step1", searchResultKey: "first", pathSegments: ["user"], fieldName: "name" } - * Example: "{{step1.totalCount}}" -> { stepId: "step1", searchResultKey: "totalCount", pathSegments: [], fieldName: undefined } - */ -const parseVariableName = (rawVariableName: string) => { - const variableWithoutBrackets = rawVariableName.replace( - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - (_, variableName) => variableName, - ); - - const parts = parseVariablePath(variableWithoutBrackets); - const stepId = parts.at(0); - const searchResultKey = parts.at(1) as SearchResultKey; - const remainingParts = parts.slice(2); - - return { - stepId, - searchResultKey, - fieldName: remainingParts.at(-1), - pathSegments: remainingParts.slice(0, -1), - }; -}; - -/** - * Searches for a variable within a search record output schema and returns its metadata - * - * @param stepName - Display name of the workflow step - * @param searchRecordOutputSchema - The search record schema to search within - * @param rawVariableName - Variable name like "{{step1.first.user.name}}" or "step1.totalCount" - * @param isFullRecord - Whether to return info for the entire record vs specific field - * @returns Variable metadata including labels, types, and field information - */ -export const searchVariableThroughFindRecordsOutputSchema = ({ - stepName, - searchRecordOutputSchema, - rawVariableName, - isFullRecord = false, - stepNameLabel, -}: { - stepName: string; - searchRecordOutputSchema: FindRecordsOutputSchema; - rawVariableName: string; - isFullRecord?: boolean; - stepNameLabel?: string; -}): VariableSearchResult => { - if (!isDefined(searchRecordOutputSchema)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - const { stepId, searchResultKey, fieldName, pathSegments } = - parseVariableName(rawVariableName); - - if (!isDefined(stepId) || !isDefined(searchResultKey)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - if (searchResultKey === 'first') { - const recordSchema = searchRecordOutputSchema[searchResultKey]?.value; - - if (!isDefined(recordSchema) || !isDefined(fieldName)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - return searchRecordOutputSchemaUtil({ - stepName: `${stepName} > ${searchRecordOutputSchema[searchResultKey]?.label ?? 'First'}`, - recordOutputSchema: recordSchema, - selectedField: fieldName, - path: pathSegments, - isFullRecord, - stepNameLabel, - }); - } - - if (searchResultKey === 'totalCount') { - const label = - searchRecordOutputSchema[searchResultKey]?.label ?? 'Total Count'; - const basePath = `${stepName} > ${label}`; - return { - variableLabel: label, - variablePathLabel: stepNameLabel - ? `${basePath} (${stepNameLabel})` - : basePath, - variableType: FieldMetadataType.NUMBER, - }; - } - - if (searchResultKey === 'all') { - const label = - searchRecordOutputSchema[searchResultKey]?.label ?? 'All Records'; - const basePath = `${stepName} > ${label}`; - return { - variableLabel: - searchRecordOutputSchema[searchResultKey]?.label ?? 'All Records', - variablePathLabel: stepNameLabel - ? `${basePath} (${stepNameLabel})` - : basePath, - variableType: FieldMetadataType.ARRAY, - }; - } - - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts deleted file mode 100644 index 43e43b53883..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; -import type { FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema'; -import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; -import { isDefined } from 'twenty-shared/utils'; -import { - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - parseVariablePath, -} from 'twenty-shared/workflow'; - -/** - * Parses a variable name to extract its components for Form outputs - * Example: "{{step1.fieldName}}" -> { stepId: "step1", fieldName: "fieldName", pathSegments: [] } - * Example: "{{step1.recordField.user.name}}" -> { stepId: "step1", fieldName: "recordField", pathSegments: ["user"], recordFieldName: "name" } - */ -const parseVariableName = (rawVariableName: string) => { - const variableWithoutBrackets = rawVariableName.replace( - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - (_, variableName) => variableName, - ); - - const parts = parseVariablePath(variableWithoutBrackets); - const stepId = parts.at(0); - const fieldName = parts.at(1); - const remainingParts = parts.slice(2); - - return { - stepId, - fieldName, - pathSegments: remainingParts.slice(0, -1), - recordFieldName: remainingParts.at(-1), - }; -}; - -/** - * Searches for a variable within a form output schema and returns its metadata - * - * @param stepName - Display name of the workflow step - * @param formOutputSchema - The form schema to search within - * @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.recordField.user.name" - * @param isFullRecord - Whether to return info for the entire record vs specific field - * @returns Variable metadata including labels, types, and field information - */ -export const searchVariableThroughFormOutputSchema = ({ - stepName, - formOutputSchema, - rawVariableName, - isFullRecord = false, -}: { - stepName: string; - formOutputSchema: FormOutputSchema; - rawVariableName: string; - isFullRecord?: boolean; -}): VariableSearchResult => { - if (!isDefined(formOutputSchema)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - const { stepId, fieldName, pathSegments, recordFieldName } = - parseVariableName(rawVariableName); - - if (!isDefined(stepId) || !isDefined(fieldName)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - const formField = formOutputSchema[fieldName]; - - if (!isDefined(formField)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - if (formField.isLeaf) { - return { - variableLabel: formField.label, - variablePathLabel: `${stepName} > ${formField.label}`, - variableType: formField.type, - }; - } - - if (!formField.isLeaf && isDefined(recordFieldName)) { - return searchRecordOutputSchema({ - stepName: `${stepName} > ${formField.label}`, - recordOutputSchema: formField.value, - selectedField: recordFieldName, - path: pathSegments, - isFullRecord, - }); - } - - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts deleted file mode 100644 index 5bee261aa45..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; -import { isBaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isBaseOutputSchemaV2'; -import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2'; -import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema'; -import { searchBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema'; -import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - parseVariablePath, -} from 'twenty-shared/workflow'; - -type IteratorResultKey = - | 'currentItem' - | 'currentItemIndex' - | 'hasProcessedAllItems'; - -/** - * Parses a variable name to extract its components - * Example: "{{step1.currentItem.field}}" -> { stepId: "step1", iteratorResultKey: "currentItem", pathSegments: [], fieldName: "field" } - */ -const parseVariableName = (rawVariableName: string) => { - const variableWithoutBrackets = rawVariableName.replace( - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - (_, variableName) => variableName, - ); - - const parts = parseVariablePath(variableWithoutBrackets); - const stepId = parts.at(0); - const iteratorResultKey = parts.at(1) as IteratorResultKey; - const remainingParts = parts.slice(2); - - return { - stepId, - iteratorResultKey, - fieldName: remainingParts.at(-1), - pathSegments: remainingParts.slice(0, -1), - }; -}; - -export const searchVariableThroughIteratorOutputSchema = ({ - stepName, - iteratorOutputSchema, - rawVariableName, - isFullRecord = false, -}: { - stepName: string; - iteratorOutputSchema: IteratorOutputSchema; - rawVariableName: string; - isFullRecord?: boolean; -}): VariableSearchResult => { - if (!isDefined(iteratorOutputSchema)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - const { stepId, iteratorResultKey, fieldName, pathSegments } = - parseVariableName(rawVariableName); - - if (!isDefined(stepId) || !isDefined(iteratorResultKey)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - if (iteratorResultKey === 'currentItemIndex') { - return { - variableLabel: 'Current Item Index', - variablePathLabel: `${stepName} > Current Item Index`, - variableType: FieldMetadataType.NUMBER, - }; - } - - if (iteratorResultKey === 'hasProcessedAllItems') { - return { - variableLabel: 'Has Processed All Items', - variablePathLabel: `${stepName} > Has Processed All Items`, - variableType: FieldMetadataType.BOOLEAN, - }; - } - - if (iteratorResultKey === 'currentItem') { - const schema = iteratorOutputSchema.currentItem.value; - - if (!isDefined(schema)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - if (isRecordOutputSchemaV2(schema) && isDefined(fieldName)) { - return searchRecordOutputSchema({ - stepName: `${stepName} > Current Item`, - recordOutputSchema: schema, - path: pathSegments, - selectedField: fieldName, - isFullRecord, - }); - } - - if (isBaseOutputSchemaV2(schema) && isDefined(fieldName)) { - return searchBaseOutputSchema({ - stepName, - baseOutputSchema: schema, - path: pathSegments, - selectedField: fieldName, - }); - } - - return { - variableLabel: iteratorOutputSchema.currentItem.label, - variablePathLabel: `${stepName} > ${iteratorOutputSchema.currentItem.label}`, - variableType: iteratorOutputSchema.currentItem.isLeaf - ? iteratorOutputSchema.currentItem.type - : 'unknown', - }; - } - - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema.ts deleted file mode 100644 index 6e1d1ad4061..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2'; -import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema'; -import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema'; -import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; - -export const searchVariableThroughManualTriggerOutputSchema = ({ - stepName, - manualTriggerOutputSchema, - rawVariableName, - isFullRecord, -}: { - stepName: string; - manualTriggerOutputSchema: ManualTriggerOutputSchema; - rawVariableName: string; - isFullRecord: boolean; -}) => { - if (isRecordOutputSchemaV2(manualTriggerOutputSchema)) { - return searchVariableThroughRecordOutputSchema({ - stepName, - recordOutputSchema: manualTriggerOutputSchema, - rawVariableName, - isFullRecord, - }); - } - - return searchVariableThroughBaseOutputSchema({ - stepName, - baseOutputSchema: manualTriggerOutputSchema, - rawVariableName, - }); -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2.ts index a5c3a259b81..019ab556d77 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2.ts @@ -2,23 +2,11 @@ import { type WorkflowActionType, type WorkflowTriggerType, } from '@/workflow/types/Workflow'; -import { isCodeOutputSchema } from '@/workflow/workflow-variables/types/guards/isCodeOutputSchema'; -import { isDatabaseEventTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isDatabaseEventTriggerOutputSchema'; -import { isFindRecordsOutputSchema } from '@/workflow/workflow-variables/types/guards/isFindRecordsOutputSchema'; -import { isFormOutputSchema } from '@/workflow/workflow-variables/types/guards/isFormOutputSchema'; -import { isIteratorOutputSchema } from '@/workflow/workflow-variables/types/guards/isIteratorOutputSchema'; -import { isManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isManualTriggerOutputSchema'; -import { isRecordStepOutputSchema } from '@/workflow/workflow-variables/types/guards/isRecordStepOutputSchema'; import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; - -import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema'; -import { searchVariableThroughCodeOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema'; -import { searchVariableThroughFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema'; -import { searchVariableThroughFormOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema'; -import { searchVariableThroughIteratorOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema'; -import { searchVariableThroughManualTriggerOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema'; -import { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema'; -import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; +import { + searchVariableInOutputSchema, + type VariableSearchResult, +} from 'twenty-shared/workflow'; export const searchVariableThroughOutputSchemaV2 = ({ stepOutputSchema, @@ -30,75 +18,13 @@ export const searchVariableThroughOutputSchemaV2 = ({ stepType: WorkflowTriggerType | WorkflowActionType; rawVariableName: string; isFullRecord: boolean; -}) => { - if (isRecordStepOutputSchema(stepType, stepOutputSchema.outputSchema)) { - return searchVariableThroughRecordOutputSchema({ - stepName: stepOutputSchema.name, - recordOutputSchema: stepOutputSchema.outputSchema, - rawVariableName, - isFullRecord, - }); - } - - if (isManualTriggerOutputSchema(stepType, stepOutputSchema.outputSchema)) { - return searchVariableThroughManualTriggerOutputSchema({ - stepName: stepOutputSchema.name, - manualTriggerOutputSchema: stepOutputSchema.outputSchema, - rawVariableName, - isFullRecord, - }); - } - - if ( - isDatabaseEventTriggerOutputSchema(stepType, stepOutputSchema.outputSchema) - ) { - return searchVariableThroughRecordEventOutputSchema({ - stepName: stepOutputSchema.name, - recordOutputSchema: stepOutputSchema.outputSchema, - rawVariableName, - isFullRecord, - }); - } - - if (isFindRecordsOutputSchema(stepType, stepOutputSchema.outputSchema)) { - return searchVariableThroughFindRecordsOutputSchema({ - stepName: stepOutputSchema.name, - searchRecordOutputSchema: stepOutputSchema.outputSchema, - rawVariableName, - isFullRecord, - stepNameLabel: stepOutputSchema.objectName, - }); - } - - if (isFormOutputSchema(stepType, stepOutputSchema.outputSchema)) { - return searchVariableThroughFormOutputSchema({ - stepName: stepOutputSchema.name, - formOutputSchema: stepOutputSchema.outputSchema, - rawVariableName, - isFullRecord, - }); - } - - if (isCodeOutputSchema(stepType, stepOutputSchema.outputSchema)) { - return searchVariableThroughCodeOutputSchema({ - stepName: stepOutputSchema.name, - codeOutputSchema: stepOutputSchema.outputSchema, - rawVariableName, - }); - } - - if (isIteratorOutputSchema(stepType, stepOutputSchema.outputSchema)) { - return searchVariableThroughIteratorOutputSchema({ - stepName: stepOutputSchema.name, - iteratorOutputSchema: stepOutputSchema.outputSchema, - rawVariableName, - isFullRecord, - }); - } - - return searchVariableThroughBaseOutputSchema({ +}): VariableSearchResult => { + return searchVariableInOutputSchema({ + schema: stepOutputSchema.outputSchema, + stepType, stepName: stepOutputSchema.name, - baseOutputSchema: stepOutputSchema.outputSchema, rawVariableName, + isFullRecord, + stepNameLabel: stepOutputSchema.objectName, }); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts deleted file mode 100644 index 16aef78b1bb..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; -import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; -import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; -import { isDefined } from 'twenty-shared/utils'; -import { - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - parseVariablePath, -} from 'twenty-shared/workflow'; - -/** - * Parses a variable name to extract its components - * Example: "{{step1.properties.after.user.name}}" -> { stepId: "step1", eventPrefix: "properties.after", pathSegments: ["user"], fieldName: "name" } - */ -const parseVariableName = (rawVariableName: string) => { - const variableWithoutBrackets = rawVariableName.replace( - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - (_, variableName) => variableName, - ); - - const parts = parseVariablePath(variableWithoutBrackets); - const stepId = parts.at(0); - // after stepId, we have a prefix (properties.after or properties.before). Path segments are the rest of the string - // join the next 3 parts to get the event prefix (properties, after/before, objectName) - const firstFieldWithEventPrefix = parts.slice(1, 4).join('.'); - const remainingParts = parts.slice(4); - const partsWithoutStepId = [firstFieldWithEventPrefix, ...remainingParts]; - - return { - stepId, - fieldName: partsWithoutStepId.at(-1), - pathSegments: partsWithoutStepId.slice(0, -1), - }; -}; - -/** - * Searches for a variable within a record output schema and returns its metadata - * - * @param stepName - Display name of the workflow step - * @param recordOutputSchema - The schema to search within - * @param rawVariableName - Variable name like "{{step1.user.name}}" or "step1.user.name" - * @param isFullRecord - Whether to return info for the entire record vs specific field - * @returns Variable metadata including labels, types, and field information - */ -export const searchVariableThroughRecordEventOutputSchema = ({ - stepName, - recordOutputSchema, - rawVariableName, - isFullRecord = false, -}: { - stepName: string; - recordOutputSchema: RecordOutputSchemaV2; - rawVariableName: string; - isFullRecord?: boolean; -}): VariableSearchResult => { - if (!isDefined(recordOutputSchema)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - const { stepId, fieldName, pathSegments } = - parseVariableName(rawVariableName); - - if (!isDefined(stepId) || !isDefined(fieldName)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - return searchRecordOutputSchema({ - stepName, - recordOutputSchema, - selectedField: fieldName, - path: pathSegments, - isFullRecord, - }); -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts deleted file mode 100644 index 5bae4ccfa53..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; -import { - type FieldOutputSchemaV2, - type RecordFieldNodeValue, - type RecordOutputSchemaV2, -} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; -import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2'; -import { isDefined } from 'twenty-shared/utils'; -import { - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - parseVariablePath, -} from 'twenty-shared/workflow'; - -const getRecordObjectLabel = ( - recordSchema: RecordOutputSchemaV2, -): string | undefined => { - return recordSchema.object.label; -}; - -const getFieldFromSchema = ( - fieldKey: string, - recordSchema: RecordFieldNodeValue, -): FieldOutputSchemaV2 | undefined => { - return isRecordOutputSchemaV2(recordSchema) - ? recordSchema.fields[fieldKey] - : recordSchema[fieldKey]; -}; - -const getCompositeSubFieldName = ( - recordSchema: RecordFieldNodeValue, - fieldKey: string, -): string | undefined => { - return isRecordOutputSchemaV2(recordSchema) - ? undefined - : recordSchema[fieldKey]?.isCompositeSubField - ? fieldKey - : undefined; -}; - -const isIdFieldName = (fieldName: string) => { - return ( - fieldName === 'id' || - // For database events, id field will have a prefix such as properties.after.id - fieldName.endsWith('.id') - ); -}; - -const navigateToTargetField = ( - startingSchema: RecordOutputSchemaV2, - pathSegments: string[], -): { schema: RecordFieldNodeValue; pathLabels: string[] } | null => { - let currentSchema: RecordFieldNodeValue = startingSchema; - const pathLabels: string[] = []; - - for (const pathSegment of pathSegments) { - const field = getFieldFromSchema(pathSegment, currentSchema); - - if (!isDefined(field)) { - return null; // Path not found - } - - if (isDefined(field.label)) { - pathLabels.push(field.label); - } - - const nextSchema = field.value; - if (!isDefined(nextSchema)) { - return null; // Dead end in path - } - - currentSchema = nextSchema; - } - - return { schema: currentSchema, pathLabels }; -}; - -const buildVariableResult = ( - stepName: string, - pathLabels: string[], - targetSchema: RecordFieldNodeValue, - targetFieldName: string, - isFullRecord: boolean, - stepNameLabel?: string, -): VariableSearchResult => { - const targetField = getFieldFromSchema(targetFieldName, targetSchema); - // Determine the variable label based on whether we want the full record or a specific field - const variableLabel = - isFullRecord && - isRecordOutputSchemaV2(targetSchema) && - isIdFieldName(targetFieldName) - ? getRecordObjectLabel(targetSchema) - : targetField?.label; - - if (!variableLabel) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - variableType: undefined, - }; - } - - // Build the full path: stepName > field1 > field2 > targetField - const fullPathSegments = [stepName, ...pathLabels, variableLabel]; - const basePath = fullPathSegments.join(' > '); - const variablePathLabel = stepNameLabel - ? `${basePath} (${stepNameLabel})` - : basePath; - - return { - variableLabel, - variablePathLabel, - variableType: targetField?.type, - fieldMetadataId: targetField?.fieldMetadataId, - compositeFieldSubFieldName: getCompositeSubFieldName( - targetSchema, - targetFieldName, - ), - }; -}; - -export const searchRecordOutputSchema = ({ - stepName, - recordOutputSchema, - path, - selectedField, - isFullRecord, - stepNameLabel, -}: { - stepName: string; - recordOutputSchema: RecordOutputSchemaV2; - path: string[]; - selectedField: string; - isFullRecord: boolean; - stepNameLabel?: string; -}): VariableSearchResult => { - const navigationResult = navigateToTargetField(recordOutputSchema, path); - - if (!navigationResult) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - variableType: undefined, - }; - } - - return buildVariableResult( - stepName, - navigationResult.pathLabels, - navigationResult.schema, - selectedField, - isFullRecord, - stepNameLabel, - ); -}; - -/** - * Parses a variable name to extract its components - * Example: "{{step1.user.name}}" -> { stepId: "step1", pathSegments: ["user"], fieldName: "name" } - */ -const parseVariableName = (rawVariableName: string) => { - const variableWithoutBrackets = rawVariableName.replace( - CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, - (_, variableName) => variableName, - ); - - const parts = parseVariablePath(variableWithoutBrackets); - - return { - stepId: parts.at(0), - fieldName: parts.at(-1), - pathSegments: parts.slice(1, -1), // Everything between stepId and fieldName - }; -}; - -/** - * Searches for a variable within a record output schema and returns its metadata - * - * @param stepName - Display name of the workflow step - * @param recordOutputSchema - The schema to search within - * @param rawVariableName - Variable name like "{{step1.user.name}}" or "step1.user.name" - * @param isFullRecord - Whether to return info for the entire record vs specific field - * @returns Variable metadata including labels, types, and field information - */ -export const searchVariableThroughRecordOutputSchema = ({ - stepName, - recordOutputSchema, - rawVariableName, - isFullRecord = false, -}: { - stepName: string; - recordOutputSchema: RecordOutputSchemaV2; - rawVariableName: string; - isFullRecord?: boolean; -}): VariableSearchResult => { - if (!isDefined(recordOutputSchema)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - const { stepId, fieldName, pathSegments } = - parseVariableName(rawVariableName); - - if (!isDefined(stepId) || !isDefined(fieldName)) { - return { - variableLabel: undefined, - variablePathLabel: undefined, - }; - } - - return searchRecordOutputSchema({ - stepName, - recordOutputSchema, - selectedField: fieldName, - path: pathSegments, - isFullRecord, - }); -}; diff --git a/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts b/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts index a14772a3e6f..3235265e531 100644 --- a/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts +++ b/packages/twenty-front/src/utils/__tests__/checkUrlType.test.ts @@ -1,28 +1,30 @@ import { checkUrlType } from '~/utils/checkUrlType'; +import { LinkType } from 'twenty-ui-deprecated/navigation'; describe('checkUrlType', () => { - it('should return "linkedin", if linkedin url', () => { - expect(checkUrlType('https://www.linkedin.com/in/håkan-fisk')).toBe( - 'linkedin', + it('should detect LinkedIn urls', () => { + expect(checkUrlType('https://www.linkedin.com/in/john')).toBe( + LinkType.LinkedIn, ); - expect(checkUrlType('http://www.linkedin.com/in/håkan-fisk')).toBe( - 'linkedin', + expect(checkUrlType('linkedin.com/company/twenty')).toBe(LinkType.LinkedIn); + }); + + it('should detect Twitter urls', () => { + expect(checkUrlType('https://twitter.com/twenty')).toBe(LinkType.Twitter); + }); + + it('should detect X urls as Twitter', () => { + expect(checkUrlType('https://x.com/twenty')).toBe(LinkType.Twitter); + }); + + it('should detect Facebook urls', () => { + expect(checkUrlType('https://www.facebook.com/twenty')).toBe( + LinkType.Facebook, ); - expect(checkUrlType('https://linkedin.com/in/håkan-fisk')).toBe('linkedin'); - expect(checkUrlType('http://linkedin.com/in/håkan-fisk')).toBe('linkedin'); - expect(checkUrlType('linkedin.com/in/håkan-fisk')).toBe('linkedin'); }); - it('should return "twitter", if twitter url', () => { - expect(checkUrlType('https://www.twitter.com/john-doe')).toBe('twitter'); - expect(checkUrlType('https://www.x.com/john-doe')).toBe('twitter'); - }); - - it('should return "url", if neither linkedin nor twitter url', () => { - expect(checkUrlType('https://www.example.com')).toBe('url'); - }); - - it('should return "facebook", if facebook url', () => { - expect(checkUrlType('https://www.facebook.com/john-doe')).toBe('facebook'); + it('should fall back to a generic url type', () => { + expect(checkUrlType('https://example.com')).toBe(LinkType.Url); + expect(checkUrlType('not-a-url')).toBe(LinkType.Url); }); }); diff --git a/packages/twenty-front/src/utils/__tests__/compareNonEmptyStrings.test.ts b/packages/twenty-front/src/utils/__tests__/compareNonEmptyStrings.test.ts new file mode 100644 index 00000000000..a7a48d65bfc --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/compareNonEmptyStrings.test.ts @@ -0,0 +1,19 @@ +import { compareNonEmptyStrings } from '~/utils/compareNonEmptyStrings'; + +describe('compareNonEmptyStrings', () => { + it('should return true when both values are empty or nullish', () => { + expect(compareNonEmptyStrings(null, undefined)).toBe(true); + expect(compareNonEmptyStrings('', null)).toBe(true); + expect(compareNonEmptyStrings('', '')).toBe(true); + }); + + it('should return true when both values are equal non-empty strings', () => { + expect(compareNonEmptyStrings('foo', 'foo')).toBe(true); + }); + + it('should return false when values differ', () => { + expect(compareNonEmptyStrings('foo', 'bar')).toBe(false); + expect(compareNonEmptyStrings('foo', '')).toBe(false); + expect(compareNonEmptyStrings('foo', null)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/compareStrictlyExceptForNullAndUndefined.test.ts b/packages/twenty-front/src/utils/__tests__/compareStrictlyExceptForNullAndUndefined.test.ts index 08e2529e057..9bf7a38645f 100644 --- a/packages/twenty-front/src/utils/__tests__/compareStrictlyExceptForNullAndUndefined.test.ts +++ b/packages/twenty-front/src/utils/__tests__/compareStrictlyExceptForNullAndUndefined.test.ts @@ -1,49 +1,24 @@ import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined'; describe('compareStrictlyExceptForNullAndUndefined', () => { - it('should return true for undefined === null', () => { + it('should return true when both values are nullish', () => { + expect(compareStrictlyExceptForNullAndUndefined(null, undefined)).toBe( + true, + ); expect(compareStrictlyExceptForNullAndUndefined(undefined, null)).toBe( true, ); }); - it('should return true for null === undefined', () => { - expect(compareStrictlyExceptForNullAndUndefined(null, undefined)).toBe( - true, - ); + it('should compare strictly when at least one value is defined', () => { + expect(compareStrictlyExceptForNullAndUndefined(1, 1)).toBe(true); + expect(compareStrictlyExceptForNullAndUndefined('a', 'a')).toBe(true); + expect(compareStrictlyExceptForNullAndUndefined(1, 2)).toBe(false); + expect(compareStrictlyExceptForNullAndUndefined(1, null)).toBe(false); }); - it('should return true for undefined === undefined', () => { - expect(compareStrictlyExceptForNullAndUndefined(undefined, undefined)).toBe( - true, - ); - }); - - it('should return true for null === null', () => { - expect(compareStrictlyExceptForNullAndUndefined(null, null)).toBe(true); - }); - - it('should return true for 2 === 2', () => { - expect(compareStrictlyExceptForNullAndUndefined(2, 2)).toBe(true); - }); - - it('should return false for 2 === 3', () => { - expect(compareStrictlyExceptForNullAndUndefined(2, 3)).toBe(false); - }); - - it('should return false for undefined === 2', () => { - expect(compareStrictlyExceptForNullAndUndefined(undefined, 2)).toBe(false); - }); - - it('should return false for null === 2', () => { - expect(compareStrictlyExceptForNullAndUndefined(null, 2)).toBe(false); - }); - - it('should return false for 2 === "2"', () => { - expect(compareStrictlyExceptForNullAndUndefined(2, '2')).toBe(false); - }); - - it('should return true for "2" === "2"', () => { - expect(compareStrictlyExceptForNullAndUndefined('2', '2')).toBe(true); + it('should not treat zero or empty string as nullish', () => { + expect(compareStrictlyExceptForNullAndUndefined(0, null)).toBe(false); + expect(compareStrictlyExceptForNullAndUndefined('', undefined)).toBe(false); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step.input.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step.input.ts index 71709d68c13..36902a42e36 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step.input.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step.input.ts @@ -6,7 +6,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/ import { WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position.input'; import { WorkflowStepConnectionOptions } from 'src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions'; import { WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowActionType } from 'twenty-shared/workflow'; @InputType() export class CreateWorkflowVersionStepInput { diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-action.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-action.dto.ts index 535db6abded..9e007329eee 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-action.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-action.dto.ts @@ -4,7 +4,7 @@ import graphqlTypeJson from 'graphql-type-json'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { WorkflowStepPosition } from 'src/engine/core-modules/workflow/dtos/workflow-step-position.dto'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowActionType } from 'twenty-shared/workflow'; registerEnumType(WorkflowActionType, { name: 'WorkflowActionType', diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts index be3fba02852..ff3c58e3a33 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts @@ -74,6 +74,11 @@ Always rely on tool schema definitions: - Follow schema definitions exactly for field names, types, and structures - Schema includes validation rules and common patterns +## Validation + +The \`create_complete_workflow\` and \`update_workflow_version_step\` tools automatically run validation after their operation and include the results in the response. Review any reported errors and fix them before activating the workflow. + + ## Approach - Ask clarifying questions to understand user needs diff --git a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts index f46926f2a6a..50fd249879d 100644 --- a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts @@ -31,7 +31,7 @@ import { WorkflowStatus, type WorkflowWorkspaceEntity, } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowActionType } from 'twenty-shared/workflow'; import { WorkflowTriggerException, WorkflowTriggerExceptionCode, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts index bf59127b803..c9f8f90ee4e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts @@ -1,8 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { type WorkflowTrigger, WorkflowTriggerType, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module.ts index 57ab2aa72c5..5871a2a2b95 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module.ts @@ -1,11 +1,16 @@ import { Module } from '@nestjs/common'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; @Module({ - imports: [WorkflowCommonModule, FeatureFlagModule], + imports: [ + WorkflowCommonModule, + FeatureFlagModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], providers: [WorkflowSchemaWorkspaceService], exports: [WorkflowSchemaWorkspaceService], }) diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts index 2e7141bcf18..c4b0aacf93d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts @@ -10,10 +10,12 @@ import { navigateOutputSchemaProperty, SingleRecordAvailability, TRIGGER_STEP_ID, + WorkflowActionType, } from 'twenty-shared/workflow'; import { type DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/utils/check-string-is-database-event-action'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { generateFakeValue } from 'src/engine/utils/generate-fake-value'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const'; @@ -29,10 +31,7 @@ import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/ import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event'; import { inferArrayItemSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema'; import { type FormFieldMetadata } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowTrigger, WorkflowTriggerType, @@ -42,6 +41,7 @@ import { export class WorkflowSchemaWorkspaceService { constructor( private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, ) {} async computeStepOutputSchema({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/__tests__/workflow-validation.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/__tests__/workflow-validation.workspace-service.spec.ts new file mode 100644 index 00000000000..45f8a840bab --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/__tests__/workflow-validation.workspace-service.spec.ts @@ -0,0 +1,124 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; +import { type WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { type WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; +import { + type WorkflowAiAgentAction, + type WorkflowFindRecordsAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service'; + +const WORKSPACE_ID = 'workspace-id'; + +const ERROR_HANDLING_OPTIONS = { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, +}; + +const buildService = (objectIdByNameSingular: Record = {}) => { + const workflowCommonWorkspaceService = { + getWorkflowVersionOrFail: jest.fn(), + getFlatEntityMaps: jest.fn().mockResolvedValue({ objectIdByNameSingular }), + } as unknown as jest.Mocked; + + const workflowSchemaWorkspaceService = { + computeStepOutputSchema: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + return new WorkflowValidationWorkspaceService( + workflowCommonWorkspaceService, + workflowSchemaWorkspaceService, + ); +}; + +const buildAiAgentStep = (input: { + agentId?: string; + prompt?: string; +}): WorkflowAiAgentAction => ({ + id: 'ai-agent-step', + name: 'AI Agent', + type: WorkflowActionType.AI_AGENT, + valid: true, + settings: { + input, + outputSchema: { + result: { isLeaf: true, type: 'string', label: 'result', value: '' }, + }, + errorHandlingOptions: ERROR_HANDLING_OPTIONS, + }, +}); + +const buildFindRecordsStep = ( + objectName: string, +): WorkflowFindRecordsAction => ({ + id: 'find-records-step', + name: 'Find Records', + type: WorkflowActionType.FIND_RECORDS, + valid: true, + settings: { + input: { objectName }, + outputSchema: {}, + errorHandlingOptions: ERROR_HANDLING_OPTIONS, + }, +}); + +describe('WorkflowValidationWorkspaceService', () => { + it('should flag an AI Agent step that has no agent selected', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildAiAgentStep({})], + }); + + expect(result.errors.map((issue) => issue.code)).toContain( + 'AI_AGENT_MISSING_AGENT', + ); + }); + + it('should not flag an AI Agent step that has an agent selected', async () => { + const service = buildService(); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildAiAgentStep({ agentId: 'agent-1' })], + }); + + expect(result.errors.map((issue) => issue.code)).not.toContain( + 'AI_AGENT_MISSING_AGENT', + ); + }); + + it('should flag a record step targeting an object that does not exist in the workspace', async () => { + const service = buildService({}); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildFindRecordsStep('ghost')], + }); + + expect( + result.errors.some((issue) => + issue.message.includes('does not exist in this workspace'), + ), + ).toBe(true); + }); + + it('should not flag a record step targeting an existing object', async () => { + const service = buildService({ person: 'object-id-1' }); + + const result = await service.validateWorkflowDefinition({ + workspaceId: WORKSPACE_ID, + trigger: null, + steps: [buildFindRecordsStep('person')], + }); + + expect( + result.errors.some((issue) => + issue.message.includes('does not exist in this workspace'), + ), + ).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.module.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.module.ts new file mode 100644 index 00000000000..fec07b310c8 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; +import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module'; + +import { WorkflowValidationWorkspaceService } from './workflow-validation.workspace-service'; + +@Module({ + imports: [WorkflowCommonModule, WorkflowSchemaModule], + providers: [WorkflowValidationWorkspaceService], + exports: [WorkflowValidationWorkspaceService], +}) +export class WorkflowValidationModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts new file mode 100644 index 00000000000..bafc4283dc5 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service.ts @@ -0,0 +1,286 @@ +import { Injectable } from '@nestjs/common'; + +import { isNonEmptyString, isObject, isString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { + validateWorkflowStructure, + type WorkflowValidationIssue, + type WorkflowValidationResult, + WorkflowActionType, +} from 'twenty-shared/workflow'; + +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; +import { + type WorkflowAction, + type WorkflowAiAgentAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; + +const RECORD_CRUD_ACTION_TYPES = new Set([ + WorkflowActionType.CREATE_RECORD, + WorkflowActionType.UPDATE_RECORD, + WorkflowActionType.DELETE_RECORD, + WorkflowActionType.UPSERT_RECORD, + WorkflowActionType.FIND_RECORDS, +]); + +@Injectable() +export class WorkflowValidationWorkspaceService { + constructor( + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService, + ) {} + + async validateWorkflowVersion({ + workspaceId, + workflowVersionId, + }: { + workspaceId: string; + workflowVersionId: string; + }): Promise { + const workflowVersion = + await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ + workspaceId, + workflowVersionId, + }); + + return this.validateWorkflowDefinition({ + workspaceId, + workflowVersionId, + trigger: workflowVersion.trigger, + steps: workflowVersion.steps, + }); + } + + async validateWorkflowDefinition({ + workspaceId, + workflowVersionId, + trigger, + steps, + }: { + workspaceId: string; + workflowVersionId?: string; + trigger: WorkflowTrigger | null; + steps: WorkflowAction[] | null; + }): Promise { + const { trigger: enrichedTrigger, steps: enrichedSteps } = + await this.enrichOutputSchemas({ + workspaceId, + workflowVersionId, + trigger, + steps, + }); + + const staticResult = validateWorkflowStructure({ + trigger: enrichedTrigger, + steps: enrichedSteps, + }); + + const semanticIssues = this.validateStepTypeRequirements({ + steps: enrichedSteps ?? [], + }); + + const metadataIssues = await this.validateWorkspaceMetadata({ + workspaceId, + steps: enrichedSteps ?? [], + }); + + return mergeValidationResults(staticResult, [ + ...semanticIssues, + ...metadataIssues, + ]); + } + + private async enrichOutputSchemas({ + workspaceId, + workflowVersionId, + trigger, + steps, + }: { + workspaceId: string; + workflowVersionId?: string; + trigger: WorkflowTrigger | null; + steps: WorkflowAction[] | null; + }): Promise<{ + trigger: WorkflowTrigger | null; + steps: WorkflowAction[] | null; + }> { + const enrichedTrigger = isDefined(trigger) + ? await this.withComputedOutputSchema({ + step: trigger, + workspaceId, + workflowVersionId, + }) + : trigger; + + const enrichedSteps = isDefined(steps) + ? await Promise.all( + steps.map((step) => + this.withComputedOutputSchema({ + step, + workspaceId, + workflowVersionId, + }), + ), + ) + : steps; + + return { trigger: enrichedTrigger, steps: enrichedSteps }; + } + + private async withComputedOutputSchema< + TStep extends WorkflowTrigger | WorkflowAction, + >({ + step, + workspaceId, + workflowVersionId, + }: { + step: TStep; + workspaceId: string; + workflowVersionId?: string; + }): Promise { + try { + const computedSchema = + await this.workflowSchemaWorkspaceService.computeStepOutputSchema({ + step, + workspaceId, + workflowVersionId, + }); + + if ( + !isDefined(computedSchema) || + Object.keys(computedSchema).length === 0 + ) { + return step; + } + + return { + ...step, + settings: { ...step.settings, outputSchema: computedSchema }, + }; + } catch { + // Output schema enrichment is best-effort: if it cannot be computed, + // validation still runs against the step's existing settings rather + // than failing the whole validation. + return step; + } + } + + private validateStepTypeRequirements({ + steps, + }: { + steps: WorkflowAction[]; + }): WorkflowValidationIssue[] { + const issues: WorkflowValidationIssue[] = []; + + for (const step of steps) { + switch (step.type) { + case WorkflowActionType.AI_AGENT: + issues.push(...this.validateAiAgentStep(step)); + break; + } + } + + return issues; + } + + private validateAiAgentStep( + step: WorkflowAiAgentAction, + ): WorkflowValidationIssue[] { + const issues: WorkflowValidationIssue[] = []; + + if (!isNonEmptyString(step.settings?.input?.agentId)) { + issues.push({ + severity: 'error', + code: 'AI_AGENT_MISSING_AGENT', + message: `AI Agent step "${step.name ?? step.id}" has no agent selected.`, + stepId: step.id, + }); + } + + const outputSchema = step.settings?.outputSchema; + const hasOutputSchema = + isDefined(outputSchema) && Object.keys(outputSchema).length > 0; + + if (!hasOutputSchema) { + issues.push({ + severity: 'warning', + code: 'AI_AGENT_MISSING_OUTPUT_VARIABLE', + message: `AI Agent step "${step.name ?? step.id}" has no output variable defined. Downstream steps won't be able to reference its result.`, + stepId: step.id, + }); + } + + return issues; + } + + private async validateWorkspaceMetadata({ + workspaceId, + steps, + }: { + workspaceId: string; + steps: WorkflowAction[]; + }): Promise { + const recordSteps = steps.filter((step) => + RECORD_CRUD_ACTION_TYPES.has(step.type), + ); + + if (recordSteps.length === 0) { + return []; + } + + const { objectIdByNameSingular } = + await this.workflowCommonWorkspaceService.getFlatEntityMaps(workspaceId); + + const issues: WorkflowValidationIssue[] = []; + + for (const step of recordSteps) { + const input = step.settings.input; + const objectName = + isObject(input) && 'objectName' in input ? input.objectName : undefined; + + if (!isString(objectName)) { + issues.push({ + severity: 'error', + code: 'INVALID_STEP_PARAMS', + message: `Step "${step.name ?? step.id}" has an invalid object name.`, + stepId: step.id, + }); + + continue; + } + + if (!isDefined(objectIdByNameSingular[objectName])) { + issues.push({ + severity: 'error', + code: 'INVALID_STEP_PARAMS', + message: `Step "${step.name ?? step.id}" targets object "${objectName}" which does not exist in this workspace.`, + stepId: step.id, + }); + } + } + + return issues; + } +} + +const mergeValidationResults = ( + baseResult: WorkflowValidationResult, + additionalIssues: WorkflowValidationIssue[], +): WorkflowValidationResult => { + const errors = [ + ...baseResult.errors, + ...additionalIssues.filter((issue) => issue.severity === 'error'), + ]; + const warnings = [ + ...baseResult.warnings, + ...additionalIssues.filter((issue) => issue.severity === 'warning'), + ]; + + return { + valid: errors.length === 0, + errors, + warnings, + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-edge/__tests__/workflow-version-edge.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-edge/__tests__/workflow-version-edge.workspace-service.spec.ts index 0a1effb5570..7c553b15ebd 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-edge/__tests__/workflow-version-edge.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-edge/__tests__/workflow-version-edge.workspace-service.spec.ts @@ -1,16 +1,13 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; +import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; type MockWorkspaceRepository = Partial< diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service.ts index acff5967d56..f5d5c8f8ecb 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; +import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @@ -16,10 +16,7 @@ import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/ import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util'; import { WorkflowStepConnectionOptions } from 'src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; @Injectable() diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts index 893399c8855..566e62b96ec 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts @@ -1,6 +1,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { WorkflowActionType } from 'twenty-shared/workflow'; import { SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS } from 'twenty-shared/logic-function'; import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service'; @@ -17,10 +18,7 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service'; import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; const mockWorkspaceId = 'workspace-id'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step.workspace-service.spec.ts index 15601291095..c2c8ca2e69c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step.workspace-service.spec.ts @@ -1,6 +1,6 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; +import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; @@ -13,10 +13,7 @@ import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service'; import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; jest.mock( diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/__tests__/extract-code-step-logic-function-ids-from-workflow-steps.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/__tests__/extract-code-step-logic-function-ids-from-workflow-steps.util.spec.ts index c6f4ac49236..bc2f7922a3e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/__tests__/extract-code-step-logic-function-ids-from-workflow-steps.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/__tests__/extract-code-step-logic-function-ids-from-workflow-steps.util.spec.ts @@ -4,7 +4,7 @@ import { extractCodeStepLogicFunctionIdsFromWorkflowSteps, type NonCodeStepForLogicFunctionIdExtraction, } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/extract-code-step-logic-function-ids-from-workflow-steps.util'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowActionType } from 'twenty-shared/workflow'; const buildCodeStep = ( logicFunctionId: string, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/extract-code-step-logic-function-ids-from-workflow-steps.util.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/extract-code-step-logic-function-ids-from-workflow-steps.util.ts index fc3b58d8ea7..8d41dc634ca 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/extract-code-step-logic-function-ids-from-workflow-steps.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/extract-code-step-logic-function-ids-from-workflow-steps.util.ts @@ -4,11 +4,11 @@ import { WorkflowVersionStepException, WorkflowVersionStepExceptionCode, } from 'src/modules/workflow/common/exceptions/workflow-version-step.exception'; +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowCodeActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-input.type'; import { type WorkflowAction, type WorkflowCodeAction, - WorkflowActionType, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; export type CodeStepForLogicFunctionIdExtraction = Pick< diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions.ts index b0c81095092..f755ad989d0 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions.ts @@ -1,4 +1,4 @@ -import { type WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowActionType } from 'twenty-shared/workflow'; type WorkflowIteratorStepConnectionOptions = { connectedStepType: WorkflowActionType.ITERATOR; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/insert-step.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/insert-step.spec.ts index 42245470087..10a27e5835b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/insert-step.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/insert-step.spec.ts @@ -1,10 +1,9 @@ -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; +import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step'; import { type WorkflowAction, type WorkflowIteratorAction, - WorkflowActionType, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { type WorkflowTrigger, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/remove-step.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/remove-step.spec.ts index 50a6ed6b833..a9aad9d1af3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/remove-step.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/remove-step.spec.ts @@ -2,13 +2,10 @@ // @ts-nocheck // Disabled type checking due to tsgo performance issue with deep spread operations // See: https://github.com/microsoft/typescript-go/issues/2551 -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; +import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { type WorkflowTrigger, WorkflowTriggerType, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step.ts index 14d42edc1e1..03f046ca43d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step.ts @@ -1,5 +1,5 @@ import { isDefined } from 'twenty-shared/utils'; -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; +import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; import { WorkflowVersionStepException, @@ -7,10 +7,7 @@ import { } from 'src/modules/workflow/common/exceptions/workflow-version-step.exception'; import { type WorkflowStepConnectionOptions } from 'src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions'; import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; export const insertStep = ({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step.ts index b3d1c9ba763..2662d072ab1 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step.ts @@ -3,13 +3,13 @@ import { IF_ELSE_BRANCH_POSITION_OFFSETS, TRIGGER_STEP_ID, type StepIfElseBranch, + WorkflowActionType, } from 'twenty-shared/workflow'; import { v4 } from 'uuid'; import { isWorkflowEmptyAction } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/guards/is-workflow-empty-action.guard'; import { isWorkflowIfElseAction } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/guards/is-workflow-if-else-action.guard'; import { - WorkflowActionType, type WorkflowAction, type WorkflowIteratorAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index 2395857bcac..a862f5369da 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -12,6 +12,7 @@ import { IF_ELSE_BRANCH_POSITION_OFFSETS, getFunctionInputFromInputSchema, type StepIfElseBranch, + WorkflowActionType, } from 'twenty-shared/workflow'; import { Repository } from 'typeorm'; import { v4 } from 'uuid'; @@ -44,7 +45,6 @@ import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflo import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service'; import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; import { - WorkflowActionType, type WorkflowAction, type WorkflowEmptyAction, type WorkflowFormAction, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts index e9b33d994e7..6a9806fa277 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; +import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; import { WithLock } from 'src/engine/core-modules/cache-lock/with-lock.decorator'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; @@ -25,10 +25,7 @@ import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/ import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util'; import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; -import { - WorkflowActionType, - type WorkflowAction, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; @Injectable() export class WorkflowVersionWorkspaceService { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts index c69c87e9da7..6096e2112e0 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/factories/workflow-action.factory.ts @@ -23,7 +23,7 @@ import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-execut import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action'; import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action'; import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowActionType } from 'twenty-shared/workflow'; @Injectable() export class WorkflowActionFactory { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-child-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-child-step.util.spec.ts index a3af95071c6..64c831bc3bc 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-child-step.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-child-step.util.spec.ts @@ -1,10 +1,7 @@ -import { StepStatus } from 'twenty-shared/workflow'; +import { StepStatus, WorkflowActionType } from 'twenty-shared/workflow'; import { shouldExecuteChildStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-child-step.util'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; describe('shouldExecuteChildStep', () => { const parentSteps = [ diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-step.util.spec.ts index 713caf3b273..c25ea73e602 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-step.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/__tests__/should-execute-step.util.spec.ts @@ -1,4 +1,4 @@ -import { StepStatus } from 'twenty-shared/workflow'; +import { StepStatus, WorkflowActionType } from 'twenty-shared/workflow'; import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity'; import { @@ -6,10 +6,7 @@ import { createMockIfElseStep, } from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util'; import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; describe('shouldExecuteStep', () => { const steps = [ diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util.ts index 1a7adb22bd3..a9dfadd6a6e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util.ts @@ -1,10 +1,12 @@ -import { type StepIfElseBranch } from 'twenty-shared/workflow'; +import { + type StepIfElseBranch, + WorkflowActionType, +} from 'twenty-shared/workflow'; import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type'; import { type WorkflowIfElseActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/types/workflow-if-else-action-settings.type'; import { type WorkflowIteratorActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type'; import { - WorkflowActionType, type WorkflowCodeAction, type WorkflowIfElseAction, type WorkflowIteratorAction, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/guards/is-workflow-ai-agent-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/guards/is-workflow-ai-agent-action.guard.ts index 591bb134207..d679dc3503c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/guards/is-workflow-ai-agent-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/guards/is-workflow-ai-agent-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowAiAgentAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/guards/is-workflow-code-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/guards/is-workflow-code-action.guard.ts index 48180c2bb97..6a32ffe1a67 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/guards/is-workflow-code-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/guards/is-workflow-code-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowCodeAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/delay/guards/is-workflow-delay-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/delay/guards/is-workflow-delay-action.guard.ts index beaecc5c5fa..48d57d204eb 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/delay/guards/is-workflow-delay-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/delay/guards/is-workflow-delay-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowDelayAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/empty/guards/is-workflow-empty-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/empty/guards/is-workflow-empty-action.guard.ts index d8e47e98e45..4020cf4eb11 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/empty/guards/is-workflow-empty-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/empty/guards/is-workflow-empty-action.guard.ts @@ -1,7 +1,5 @@ -import { - WorkflowActionType, - type WorkflowAction, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowActionType } from 'twenty-shared/workflow'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; export const isWorkflowEmptyAction = ( action: WorkflowAction, 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 index abfd3edfc88..cec4ebee290 100644 --- 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 @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowFilterAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/form/guards/is-workflow-form-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/form/guards/is-workflow-form-action.guard.ts index 539aff925bb..396ba567fce 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/form/guards/is-workflow-form-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/form/guards/is-workflow-form-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowFormAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/__tests__/http-request.workflow-action.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/__tests__/http-request.workflow-action.spec.ts index 92ac6f18a0d..9b831cc91c5 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/__tests__/http-request.workflow-action.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/__tests__/http-request.workflow-action.spec.ts @@ -1,12 +1,10 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { WorkflowActionType } from 'twenty-shared/workflow'; import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action'; import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; const baseSettings: WorkflowActionSettings = { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/guards/is-workflow-http-request-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/guards/is-workflow-http-request-action.guard.ts index 2daee1f7d15..9f9bc58623f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/guards/is-workflow-http-request-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/guards/is-workflow-http-request-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowHttpRequestAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/if-else/guards/is-workflow-if-else-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/if-else/guards/is-workflow-if-else-action.guard.ts index 629ba65a108..6486226f0b8 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/if-else/guards/is-workflow-if-else-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/if-else/guards/is-workflow-if-else-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowIfElseAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/__tests__/iterator-action.workflow-action.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/__tests__/iterator-action.workflow-action.spec.ts index 4e38237b4c0..0fba9e256ab 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/__tests__/iterator-action.workflow-action.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/__tests__/iterator-action.workflow-action.spec.ts @@ -1,15 +1,12 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { StepStatus } from 'twenty-shared/workflow'; +import { StepStatus, WorkflowActionType } from 'twenty-shared/workflow'; import { WorkflowStepExecutorException } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; import { IteratorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action'; import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type'; import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; describe('IteratorWorkflowAction', () => { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard.ts index a1f86846927..6f0c55d1a32 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowIteratorAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/guards/is-workflow-logic-function-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/guards/is-workflow-logic-function-action.guard.ts index a65db5f641b..4d049f18434 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/guards/is-workflow-logic-function-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/guards/is-workflow-logic-function-action.guard.ts @@ -1,7 +1,5 @@ -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowActionType } from 'twenty-shared/workflow'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; export const isWorkflowLogicFunctionAction = ( action: WorkflowAction, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/draft-email.workflow-action.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/draft-email.workflow-action.spec.ts index b0b56732b57..ee65d686403 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/draft-email.workflow-action.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/draft-email.workflow-action.spec.ts @@ -1,12 +1,10 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { WorkflowActionType } from 'twenty-shared/workflow'; import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool'; import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action'; import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; jest.mock( diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/send-email.workflow-action.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/send-email.workflow-action.spec.ts index 1db264897d5..210eb049930 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/send-email.workflow-action.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/__tests__/send-email.workflow-action.spec.ts @@ -1,12 +1,10 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { WorkflowActionType } from 'twenty-shared/workflow'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool'; import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action'; import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service'; jest.mock( diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard.ts index ca1c141b8c2..f5f2ac72158 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-draft-email-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowDraftEmailAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-send-email-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-send-email-action.guard.ts index 6949e9f3c7c..a0c26970607 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-send-email-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/guards/is-workflow-send-email-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowSendEmailAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-delete-record-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-delete-record-action.guard.ts index d6374cea4ef..d8ab5634917 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-delete-record-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-delete-record-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowDeleteRecordAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-find-records-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-find-records-action.guard.ts index 41b285e18df..be2c3496986 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-find-records-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-find-records-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowFindRecordsAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-update-record-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-update-record-action.guard.ts index 6af55cc422a..be8c001b743 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-update-record-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-update-record-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowUpdateRecordAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-upsert-record-action.guard.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-upsert-record-action.guard.ts index f0d3b42f77d..69164762cee 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-upsert-record-action.guard.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-upsert-record-action.guard.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowAction, - WorkflowActionType, type WorkflowUpsertRecordAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; 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 b1fed6362d5..64220d0ab5e 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,3 +1,5 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; + import { type WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type'; import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type'; import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-settings.type'; @@ -17,12 +19,6 @@ import { } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type'; import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -// Import the enum from its dedicated file to avoid circular dependencies -import { WorkflowActionType } from './workflow-action-type.enum'; - -// Re-export for consumers -export { WorkflowActionType }; - type BaseWorkflowAction = { id: string; name: string; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts index 64f8947e50f..3855f35112a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts @@ -1,6 +1,10 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { getWorkflowRunContext, StepStatus } from 'twenty-shared/workflow'; +import { + getWorkflowRunContext, + StepStatus, + WorkflowActionType, +} from 'twenty-shared/workflow'; import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; @@ -17,10 +21,7 @@ import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/fa import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util'; import { shouldFailSafely } from 'src/modules/workflow/workflow-executor/utils/should-fail-safely.util'; import { shouldSkipStepExecution } from 'src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts index 24e4fd53a33..1a8c4c546f3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts @@ -8,6 +8,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; +import { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service'; import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service'; import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; @@ -27,6 +28,7 @@ import { createUpdateLogicFunctionSourceTool } from 'src/modules/workflow/workfl import { createUpdateWorkflowVersionPositionsTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool'; import { createUpdateWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool'; import { createUpdateWorkflowVersionTriggerTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-trigger.tool'; +import { createValidateWorkflowTool } from 'src/modules/workflow/workflow-tools/tools/validate-workflow.tool'; import { type WorkflowToolDependencies } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service'; @@ -41,6 +43,7 @@ export class WorkflowToolWorkspaceService { workflowVersionService: WorkflowVersionWorkspaceService, workflowTriggerService: WorkflowTriggerWorkspaceService, workflowSchemaService: WorkflowSchemaWorkspaceService, + workflowValidationService: WorkflowValidationWorkspaceService, globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, recordPositionService: RecordPositionService, logicFunctionFromSourceService: LogicFunctionFromSourceService, @@ -53,6 +56,7 @@ export class WorkflowToolWorkspaceService { workflowVersionService, workflowTriggerService, workflowSchemaService, + workflowValidationService, globalWorkspaceOrmManager, recordPositionService, logicFunctionFromSourceService, @@ -124,6 +128,7 @@ export class WorkflowToolWorkspaceService { this.deps, context, ); + const validateWorkflow = createValidateWorkflowTool(this.deps, context); return { [createCompleteWorkflow.name]: createCompleteWorkflow, @@ -141,6 +146,7 @@ export class WorkflowToolWorkspaceService { [getWorkflowCurrentVersion.name]: getWorkflowCurrentVersion, [updateLogicFunctionSource.name]: updateLogicFunctionSource, [listLogicFunctionTools.name]: listLogicFunctionTools, + [validateWorkflow.name]: validateWorkflow, }; } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts index f0e14c654dc..1d685c3d997 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts @@ -7,12 +7,12 @@ import { z } from 'zod'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; -import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; -import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { WorkflowVersionStepException, WorkflowVersionStepExceptionCode, } from 'src/modules/workflow/common/exceptions/workflow-version-step.exception'; +import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; +import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { type WorkflowToolContext, @@ -70,6 +70,7 @@ type CreateCompleteWorkflowToolDeps = Pick< | 'workflowTriggerService' | 'globalWorkspaceOrmManager' | 'recordPositionService' + | 'workflowValidationService' >; type CreateCompleteWorkflowToolContext = WorkflowToolContext & { @@ -182,6 +183,13 @@ This is the most efficient way for AI to create workflows as it handles all the }); } + const validation = + await deps.workflowValidationService.validateWorkflowDefinition({ + workspaceId: context.workspaceId, + trigger: parameters.trigger, + steps: parameters.steps, + }); + return { success: true, message: `Workflow "${parameters.name}" created successfully with ${parameters.steps.length} steps`, @@ -191,6 +199,7 @@ This is the most efficient way for AI to create workflows as it handles all the name: parameters.name, trigger: parameters.trigger, steps: parameters.steps, + validation, }, recordReferences: [ { diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool.ts index b51b2cb48c4..6e01391d50c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { z } from 'zod'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; import { type WorkflowToolContext, type WorkflowToolDependencies, diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts index d1212f1166a..eeb354dae3c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts @@ -1,10 +1,9 @@ import { isDefined } from 'twenty-shared/utils'; -import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; +import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; import { z } from 'zod'; import type { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step.input'; import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; import { type WorkflowToolContext, type WorkflowToolDependencies, @@ -85,7 +84,7 @@ const enrichResultWithNextStep = ({ return { ...result, nextStep: - 'This CODE step was created with a default placeholder function. You MUST now call update_logic_function_source with the logicFunctionId from this step to define the actual code.', + 'This CODE step was created with a default placeholder function. You MUST now call update_logic_function_source with the logicFunctionId from this step to define the actual code. IMPORTANT: Also provide outputSchema (an example return value, e.g. { datePlus7: "2026-06-16" }) so downstream steps can reference this step\'s output variables via {{stepId.fieldName}}.', }; default: return result; diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool.ts index f3b0b12b4b6..5ac389b1458 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool.ts @@ -1,6 +1,6 @@ +import { WorkflowActionType } from 'twenty-shared/workflow'; import { z } from 'zod'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; import { type WorkflowToolContext, type WorkflowToolDependencies, diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts index 3236e9110aa..b5099758a52 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts @@ -18,7 +18,10 @@ const updateWorkflowVersionStepSchema = z.object({ }); export const createUpdateWorkflowVersionStepTool = ( - deps: Pick, + deps: Pick< + WorkflowToolDependencies, + 'workflowVersionStepService' | 'workflowValidationService' + >, context: WorkflowToolContext, ) => ({ name: 'update_workflow_version_step' as const, @@ -26,8 +29,10 @@ export const createUpdateWorkflowVersionStepTool = ( 'Update an existing step in a workflow version. This modifies the step configuration.', inputSchema: updateWorkflowVersionStepSchema, execute: async (parameters: UpdateWorkflowVersionStepInput) => { + let result; + try { - return await deps.workflowVersionStepService.updateWorkflowVersionStep({ + result = await deps.workflowVersionStepService.updateWorkflowVersionStep({ workspaceId: context.workspaceId, workflowVersionId: parameters.workflowVersionId, step: parameters.step, @@ -39,5 +44,23 @@ export const createUpdateWorkflowVersionStepTool = ( message: `Failed to update workflow version step: ${error.message}`, }; } + + try { + const validation = + await deps.workflowValidationService.validateWorkflowVersion({ + workspaceId: context.workspaceId, + workflowVersionId: parameters.workflowVersionId, + }); + return { + ...result, + validation, + }; + } catch (error) { + return { + ...result, + validationError: error.message, + message: `Step updated successfully, but validation could not be computed: ${error.message}`, + }; + } }, }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/validate-workflow.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/validate-workflow.tool.ts new file mode 100644 index 00000000000..f036e564a90 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/validate-workflow.tool.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { type WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service'; +import { type WorkflowToolContext } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const validateWorkflowSchema = z.object({ + workflowVersionId: z + .string() + .uuid() + .describe('The UUID of the workflow version to validate'), +}); + +type ValidateWorkflowInput = z.infer; + +export const createValidateWorkflowTool = ( + deps: { + workflowValidationService: WorkflowValidationWorkspaceService; + }, + context: WorkflowToolContext, +) => ({ + name: 'validate_workflow' as const, + description: + 'Validate a workflow version for correctness. Checks graph topology (connections, reachability, branches, loops), per-step configuration, references to other objects, and variable references between steps. Returns a list of errors and warnings to fix. Does not block or modify the workflow.', + inputSchema: validateWorkflowSchema, + execute: async (parameters: ValidateWorkflowInput) => { + try { + const result = + await deps.workflowValidationService.validateWorkflowVersion({ + workspaceId: context.workspaceId, + workflowVersionId: parameters.workflowVersionId, + }); + + return { + success: true, + valid: result.valid, + errors: result.errors, + warnings: result.warnings, + message: result.valid + ? 'The workflow is valid.' + : `The workflow has ${result.errors.length} error(s) that should be fixed.`, + }; + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to validate workflow: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type.ts index 11072365e88..ae71996652b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type.ts @@ -3,6 +3,7 @@ import type { LogicFunctionFromSourceService } from 'src/engine/metadata-modules import type { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import type { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import type { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; +import type { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service'; import type { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service'; import type { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service'; import type { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; @@ -16,6 +17,7 @@ export type WorkflowToolDependencies = { workflowVersionService: WorkflowVersionWorkspaceService; workflowTriggerService: WorkflowTriggerWorkspaceService; workflowSchemaService: WorkflowSchemaWorkspaceService; + workflowValidationService: WorkflowValidationWorkspaceService; globalWorkspaceOrmManager: GlobalWorkspaceOrmManager; recordPositionService: RecordPositionService; logicFunctionFromSourceService: LogicFunctionFromSourceService; diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts index ea0a1f4cc3a..bb926a6c018 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts @@ -5,6 +5,7 @@ import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provid import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module'; import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module'; +import { WorkflowValidationModule } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.module'; import { WorkflowVersionEdgeModule } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.module'; import { WorkflowVersionStepModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module'; import { WorkflowVersionModule } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.module'; @@ -22,6 +23,7 @@ import { WorkflowToolWorkspaceService } from './services/workflow-tool.workspace WorkflowVersionModule, WorkflowTriggerModule, WorkflowSchemaModule, + WorkflowValidationModule, RecordPositionModule, LogicFunctionModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts index e306cd865c1..8a7a5211ec6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts @@ -4,11 +4,9 @@ import { WorkflowVersionStatus, type WorkflowVersionWorkspaceEntity, } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; +import { WorkflowActionType } from 'twenty-shared/workflow'; import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; -import { - type WorkflowAction, - WorkflowActionType, -} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowTriggerException, WorkflowTriggerExceptionCode, diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 5d27c9adac5..07484acba52 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -79,6 +79,7 @@ export type { InputSchema, } from './types/InputSchema'; export type { StepIfElseBranch } from './types/StepIfElseBranch'; +export { WorkflowActionType } from './types/WorkflowActionType'; export type { WorkflowAttachment } from './types/WorkflowAttachment'; export type { BodyType } from './types/workflowHttpRequestStep'; export type { @@ -104,6 +105,33 @@ export { joinVariablePath, parseVariablePath, } from './utils/variable-path.util'; +export { isIfElseStepInput } from './validation/guards/isIfElseStepInput'; +export { isIteratorStepInput } from './validation/guards/isIteratorStepInput'; +export type { + IfElseStepInput, + IteratorStepInput, + WorkflowValidationSeverity, + WorkflowValidationIssueCode, + WorkflowValidationIssue, + WorkflowValidationResult, + ValidatableWorkflowStep, + ValidatableWorkflowTrigger, + ValidatableWorkflow, +} from './validation/types/workflow-validation.type'; +export type { WorkflowGraph } from './validation/utils/build-workflow-graph.util'; +export { buildWorkflowGraph } from './validation/utils/build-workflow-graph.util'; +export { extractVariablesFromInput } from './validation/utils/extract-variables-from-input.util'; +export { getEditDistance } from './validation/utils/get-edit-distance.util'; +export { + getStepInput, + getStepOutgoingStepIds, +} from './validation/utils/get-step-outgoing-step-ids.util'; +export { getVariablePathSuggestions } from './validation/utils/get-variable-path-suggestions.util'; +export { validateWorkflowGraph } from './validation/utils/validate-workflow-graph.util'; +export { validateWorkflowStepParams } from './validation/utils/validate-workflow-step-params.util'; +export { validateWorkflowVariableReferences } from './validation/utils/validate-workflow-variable-references.util'; +export { validateWorkflowStructure } from './validation/validate-workflow-structure.util'; +export { isBaseOutputSchemaV2 } from './workflow-schema/guards/isBaseOutputSchemaV2'; export type { LeafType, NodeType, @@ -111,7 +139,38 @@ export type { Node, BaseOutputSchemaV2, } from './workflow-schema/types/base-output-schema.type'; -export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigateOutputSchemaProperty'; +export type { + RecordFieldLeaf, + RecordFieldNode, + RecordFieldNodeValue, + FieldOutputSchemaV2, + RecordOutputSchemaV2, + RecordNode, + FindRecordsOutputSchema, + IteratorOutputSchema, + FormFieldLeaf, + FormFieldNode, + FormOutputSchema, + LinkOutputSchema, + CodeOutputSchema, + ManualTriggerOutputSchema, + OutputSchemaV2, + VariableSearchResult, +} from './workflow-schema/types/output-schema.type'; +export { collectOutputSchemaPaths } from './workflow-schema/utils/collect-output-schema-paths'; +export type { OutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure'; +export { findOutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure'; +export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigate-output-schema-property'; +export type { ResolvedVariable } from './workflow-schema/utils/resolve-variable-path-in-output-schema'; +export { + resolveInSchema, + resolveVariablePathInOutputSchema, + collectOutputSchemaVariablePaths, +} from './workflow-schema/utils/resolve-variable-path-in-output-schema'; +export { + searchRecordOutputSchema, + searchVariableInOutputSchema, +} from './workflow-schema/utils/search-variable-in-output-schema'; export type { GlobalAvailability, SingleRecordAvailability, diff --git a/packages/twenty-shared/src/workflow/schemas/base-workflow-action-schema.ts b/packages/twenty-shared/src/workflow/schemas/base-workflow-action-schema.ts index 1edc32c1be7..10deafe75bf 100644 --- a/packages/twenty-shared/src/workflow/schemas/base-workflow-action-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/base-workflow-action-schema.ts @@ -2,9 +2,9 @@ import { z } from 'zod'; export const baseWorkflowActionSchema = z.object({ id: z - .string() + .uuid() .describe( - 'Unique identifier for the workflow step. Must be unique within the workflow.', + 'Unique UUID identifier for the workflow step. Must be a valid UUID v4, unique within the workflow.', ), name: z .string() @@ -17,7 +17,7 @@ export const baseWorkflowActionSchema = z.object({ 'Whether the step configuration is valid. Set to true when all required fields are properly configured.', ), nextStepIds: z - .array(z.string()) + .array(z.uuid()) .optional() .nullable() .describe( diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts b/packages/twenty-shared/src/workflow/types/WorkflowActionType.ts similarity index 100% rename from packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts rename to packages/twenty-shared/src/workflow/types/WorkflowActionType.ts diff --git a/packages/twenty-shared/src/workflow/validation/__tests__/validate-workflow-structure.util.test.ts b/packages/twenty-shared/src/workflow/validation/__tests__/validate-workflow-structure.util.test.ts new file mode 100644 index 00000000000..47cf8c5ef0d --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/__tests__/validate-workflow-structure.util.test.ts @@ -0,0 +1,49 @@ +import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type'; +import { validateWorkflowStructure } from '../validate-workflow-structure.util'; + +const getCodes = (workflow: ValidatableWorkflow): string[] => { + const result = validateWorkflowStructure(workflow); + + return [...result.errors, ...result.warnings].map((issue) => issue.code); +}; + +describe('validateWorkflowStructure', () => { + it('should flag a missing trigger and missing steps', () => { + const result = validateWorkflowStructure({ + trigger: undefined, + steps: [], + }); + + expect(result.valid).toBe(false); + expect(result.errors.map((issue) => issue.code)).toEqual( + expect.arrayContaining(['MISSING_TRIGGER', 'NO_STEPS']), + ); + }); + + it('should flag a trigger without a type', () => { + expect(getCodes({ trigger: {}, steps: [] })).toContain( + 'MISSING_TRIGGER_TYPE', + ); + }); + + it('should flag a workflow without steps', () => { + expect(getCodes({ trigger: { type: 'MANUAL' }, steps: [] })).toContain( + 'NO_STEPS', + ); + }); + + it('should aggregate graph issues such as unreachable steps', () => { + const result = validateWorkflowStructure({ + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [ + { id: 's1', type: 'CODE' }, + { id: 'orphan', type: 'CODE' }, + ], + }); + + expect(result.valid).toBe(false); + expect(result.errors.map((issue) => issue.code)).toContain( + 'UNREACHABLE_STEP', + ); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/guards/isIfElseStepInput.ts b/packages/twenty-shared/src/workflow/validation/guards/isIfElseStepInput.ts new file mode 100644 index 00000000000..d65db26a30d --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/guards/isIfElseStepInput.ts @@ -0,0 +1,22 @@ +import { isObject } from '@sniptt/guards'; + +import { WorkflowActionType } from '@/workflow/types/WorkflowActionType'; +import { + type IfElseStepInput, + type ValidatableWorkflowStep, +} from '@/workflow/validation/types/workflow-validation.type'; + +export const isIfElseStepInput = ( + step: ValidatableWorkflowStep, +): step is ValidatableWorkflowStep & { + settings: { input: Partial }; +} => { + const input = step.settings?.input; + + return ( + step.type === WorkflowActionType.IF_ELSE && + isObject(input) && + 'branches' in input && + Array.isArray(input.branches) + ); +}; diff --git a/packages/twenty-shared/src/workflow/validation/guards/isIteratorStepInput.ts b/packages/twenty-shared/src/workflow/validation/guards/isIteratorStepInput.ts new file mode 100644 index 00000000000..4b3c90bfb4f --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/guards/isIteratorStepInput.ts @@ -0,0 +1,22 @@ +import { isNonEmptyArray, isObject } from '@sniptt/guards'; + +import { WorkflowActionType } from '@/workflow/types/WorkflowActionType'; +import { + type IteratorStepInput, + type ValidatableWorkflowStep, +} from '@/workflow/validation/types/workflow-validation.type'; + +export const isIteratorStepInput = ( + step: ValidatableWorkflowStep, +): step is ValidatableWorkflowStep & { + settings: { input: Partial }; +} => { + const input = step.settings?.input; + + return ( + step.type === WorkflowActionType.ITERATOR && + isObject(input) && + 'initialLoopStepIds' in input && + isNonEmptyArray(input.initialLoopStepIds) + ); +}; diff --git a/packages/twenty-shared/src/workflow/validation/types/workflow-validation.type.ts b/packages/twenty-shared/src/workflow/validation/types/workflow-validation.type.ts new file mode 100644 index 00000000000..4b62ed81a81 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/types/workflow-validation.type.ts @@ -0,0 +1,78 @@ +import { type workflowIfElseActionSettingsSchema } from '@/workflow/schemas/if-else-action-settings-schema'; +import { type workflowIteratorActionSettingsSchema } from '@/workflow/schemas/iterator-action-settings-schema'; +import { type z } from 'zod'; + +export type IfElseStepInput = z.infer< + typeof workflowIfElseActionSettingsSchema +>['input']; +export type IteratorStepInput = z.infer< + typeof workflowIteratorActionSettingsSchema +>['input']; + +export type WorkflowValidationSeverity = 'error' | 'warning'; + +export type WorkflowValidationIssueCode = + | 'MISSING_TRIGGER' + | 'MISSING_TRIGGER_TYPE' + | 'NO_STEPS' + | 'TRIGGER_HAS_NO_NEXT_STEP' + | 'DUPLICATE_STEP_ID' + | 'DANGLING_REFERENCE' + | 'UNREACHABLE_STEP' + | 'INVALID_TRIGGER_PARAMS' + | 'INVALID_STEP_PARAMS' + | 'IF_ELSE_INSUFFICIENT_BRANCHES' + | 'IF_ELSE_BRANCH_HAS_NO_NEXT_STEP' + | 'ITERATOR_MISSING_LOOP_BODY' + | 'VARIABLE_INVALID_PATH' + | 'VARIABLE_UNKNOWN_STEP' + | 'VARIABLE_NOT_UPSTREAM' + | 'VARIABLE_MISSING_OUTPUT_SCHEMA' + | 'VARIABLE_PATH_NOT_FOUND' + | 'CODE_STEP_MISSING_OUTPUT_SCHEMA' + | 'AI_AGENT_MISSING_AGENT' + | 'AI_AGENT_MISSING_OUTPUT_VARIABLE'; + +export type WorkflowValidationIssue = { + severity: WorkflowValidationSeverity; + code: WorkflowValidationIssueCode; + message: string; + stepId?: string; + path?: string; + hint?: string; + suggestions?: string[]; + availablePaths?: string[]; +}; + +export type WorkflowValidationResult = { + valid: boolean; + errors: WorkflowValidationIssue[]; + warnings: WorkflowValidationIssue[]; +}; + +export type ValidatableWorkflowStep = { + id: string; + name?: string; + type: string; + valid?: boolean; + nextStepIds?: string[] | null; + settings?: { + input?: unknown; + outputSchema?: unknown; + } | null; +}; + +export type ValidatableWorkflowTrigger = { + type?: string; + name?: string; + settings?: { + input?: unknown; + outputSchema?: unknown; + } | null; + nextStepIds?: string[] | null; +}; + +export type ValidatableWorkflow = { + trigger: ValidatableWorkflowTrigger | null | undefined; + steps: ValidatableWorkflowStep[] | null | undefined; +}; diff --git a/packages/twenty-shared/src/workflow/validation/utils/__tests__/build-workflow-graph.util.test.ts b/packages/twenty-shared/src/workflow/validation/utils/__tests__/build-workflow-graph.util.test.ts new file mode 100644 index 00000000000..0b81d3bc25d --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/__tests__/build-workflow-graph.util.test.ts @@ -0,0 +1,74 @@ +import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId'; +import { + type ValidatableWorkflow, + type ValidatableWorkflowStep, +} from '@/workflow/validation/types/workflow-validation.type'; +import { buildWorkflowGraph } from '../build-workflow-graph.util'; + +const buildStep = ( + id: string, + nextStepIds: string[] = [], +): ValidatableWorkflowStep => ({ id, type: 'CODE', nextStepIds }); + +const ancestorsOf = ( + graph: ReturnType, + stepId: string, +): string[] => [...(graph.ancestorsByStepId.get(stepId) ?? [])]; + +describe('buildWorkflowGraph', () => { + it('should map trigger children, reachability and ancestors for a linear flow', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [buildStep('s1', ['s2']), buildStep('s2')], + }; + + const graph = buildWorkflowGraph(workflow); + + expect(graph.childrenByStepId.get(TRIGGER_STEP_ID)).toEqual(['s1']); + expect(graph.reachableFromTrigger.has('s1')).toBe(true); + expect(graph.reachableFromTrigger.has('s2')).toBe(true); + expect(ancestorsOf(graph, 's1')).toEqual([TRIGGER_STEP_ID]); + expect(ancestorsOf(graph, 's2')).toEqual( + expect.arrayContaining([TRIGGER_STEP_ID, 's1']), + ); + }); + + it('should not mark disconnected steps as reachable', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [buildStep('s1'), buildStep('orphan')], + }; + + const graph = buildWorkflowGraph(workflow); + + expect(graph.reachableFromTrigger.has('s1')).toBe(true); + expect(graph.reachableFromTrigger.has('orphan')).toBe(false); + }); + + it('should handle a trigger without nextStepIds', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL' }, + steps: [buildStep('s1')], + }; + + const graph = buildWorkflowGraph(workflow); + + expect(graph.childrenByStepId.get(TRIGGER_STEP_ID)).toEqual([]); + expect(graph.reachableFromTrigger.has('s1')).toBe(false); + }); + + it('should not loop forever on cyclic graphs', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [buildStep('s1', ['s2']), buildStep('s2', ['s1'])], + }; + + const graph = buildWorkflowGraph(workflow); + + expect(graph.reachableFromTrigger.has('s1')).toBe(true); + expect(graph.reachableFromTrigger.has('s2')).toBe(true); + expect(ancestorsOf(graph, 's1')).toEqual( + expect.arrayContaining([TRIGGER_STEP_ID, 's1', 's2']), + ); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/utils/__tests__/extract-variables-from-input.util.test.ts b/packages/twenty-shared/src/workflow/validation/utils/__tests__/extract-variables-from-input.util.test.ts new file mode 100644 index 00000000000..dd35ac642c2 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/__tests__/extract-variables-from-input.util.test.ts @@ -0,0 +1,34 @@ +import { extractVariablesFromInput } from '../extract-variables-from-input.util'; + +describe('extractVariablesFromInput', () => { + it('should return an empty array for non-string, non-object input', () => { + expect(extractVariablesFromInput(undefined)).toEqual([]); + expect(extractVariablesFromInput(null)).toEqual([]); + expect(extractVariablesFromInput(42)).toEqual([]); + }); + + it('should extract a single variable from a string', () => { + expect(extractVariablesFromInput('Hello {{step1.name}}')).toEqual([ + 'step1.name', + ]); + }); + + it('should extract every variable across nested objects and arrays', () => { + const input = { + greeting: 'Hi {{trigger.email}}', + nested: { message: 'X {{step2.value}} Y {{step3.id}}' }, + list: ['{{step4.foo}}'], + }; + + expect(extractVariablesFromInput(input)).toEqual([ + 'trigger.email', + 'step2.value', + 'step3.id', + 'step4.foo', + ]); + }); + + it('should return an empty array when no variables are present', () => { + expect(extractVariablesFromInput({ a: 'plain text', b: 5 })).toEqual([]); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-edit-distance.util.test.ts b/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-edit-distance.util.test.ts new file mode 100644 index 00000000000..8823c821c30 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-edit-distance.util.test.ts @@ -0,0 +1,29 @@ +import { getEditDistance } from '../get-edit-distance.util'; + +describe('getEditDistance', () => { + it('should return 0 for identical strings', () => { + expect(getEditDistance('name', 'name')).toBe(0); + }); + + it('should count a single deletion', () => { + expect(getEditDistance('name', 'nme')).toBe(1); + }); + + it('should count a single insertion', () => { + expect(getEditDistance('nme', 'name')).toBe(1); + }); + + it('should count a single substitution', () => { + expect(getEditDistance('firstName', 'firstname')).toBe(1); + }); + + it('should compute the classic kitten/sitting distance', () => { + expect(getEditDistance('kitten', 'sitting')).toBe(3); + }); + + it('should handle empty strings', () => { + expect(getEditDistance('', 'name')).toBe(4); + expect(getEditDistance('name', '')).toBe(4); + expect(getEditDistance('', '')).toBe(0); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-step-outgoing-step-ids.util.test.ts b/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-step-outgoing-step-ids.util.test.ts new file mode 100644 index 00000000000..341e125278d --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-step-outgoing-step-ids.util.test.ts @@ -0,0 +1,81 @@ +import { WorkflowActionType } from '@/workflow/types/WorkflowActionType'; +import { type ValidatableWorkflowStep } from '@/workflow/validation/types/workflow-validation.type'; +import { + getStepInput, + getStepOutgoingStepIds, +} from '../get-step-outgoing-step-ids.util'; + +describe('getStepInput', () => { + it('should return the input object when present', () => { + const step: ValidatableWorkflowStep = { + id: 'step-1', + type: 'CODE', + settings: { input: { foo: 'bar' } }, + }; + + expect(getStepInput(step)).toEqual({ foo: 'bar' }); + }); + + it('should return undefined when input is missing or not an object', () => { + expect(getStepInput({ id: 'step-1', type: 'CODE' })).toBeUndefined(); + expect( + getStepInput({ id: 'step-1', type: 'CODE', settings: { input: 'text' } }), + ).toBeUndefined(); + }); +}); + +describe('getStepOutgoingStepIds', () => { + it('should return the nextStepIds of a regular step', () => { + expect( + getStepOutgoingStepIds({ + id: 'step-1', + type: 'CODE', + nextStepIds: ['a', 'b'], + }), + ).toEqual(['a', 'b']); + }); + + it('should deduplicate outgoing step ids', () => { + expect( + getStepOutgoingStepIds({ + id: 'step-1', + type: 'CODE', + nextStepIds: ['a', 'a'], + }), + ).toEqual(['a']); + }); + + it('should include if-else branch nextStepIds', () => { + const step: ValidatableWorkflowStep = { + id: 'step-1', + type: WorkflowActionType.IF_ELSE, + nextStepIds: ['x'], + settings: { + input: { branches: [{ nextStepIds: ['b1'] }, { nextStepIds: ['b2'] }] }, + }, + }; + + expect(getStepOutgoingStepIds(step).sort()).toEqual(['b1', 'b2', 'x']); + }); + + it('should include iterator initialLoopStepIds', () => { + const step: ValidatableWorkflowStep = { + id: 'step-1', + type: WorkflowActionType.ITERATOR, + settings: { input: { initialLoopStepIds: ['loop-1'] } }, + }; + + expect(getStepOutgoingStepIds(step)).toEqual(['loop-1']); + }); + + it('should fall back to nextStepIds when input is not an object', () => { + expect( + getStepOutgoingStepIds({ + id: 'step-1', + type: 'CODE', + nextStepIds: ['a'], + settings: { input: undefined }, + }), + ).toEqual(['a']); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-variable-path-suggestions.util.test.ts b/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-variable-path-suggestions.util.test.ts new file mode 100644 index 00000000000..264cacc0f11 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/__tests__/get-variable-path-suggestions.util.test.ts @@ -0,0 +1,183 @@ +import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type'; + +import { getVariablePathSuggestions } from '../get-variable-path-suggestions.util'; + +describe('getVariablePathSuggestions', () => { + const schema: BaseOutputSchemaV2 = { + user: { + isLeaf: false, + type: 'object', + label: 'user', + value: { + name: { + isLeaf: true, + type: 'string', + label: 'name', + value: 'Test', + }, + email: { + isLeaf: true, + type: 'string', + label: 'email', + value: 'test@test.com', + }, + }, + }, + id: { + isLeaf: true, + type: 'string', + label: 'id', + value: '1', + }, + }; + + it('should suggest the closest sibling for a nested typo (strategy A)', () => { + expect( + getVariablePathSuggestions({ + schema, + propertyPath: ['user', 'naem'], + referencedStepId: 'step-1', + }), + ).toEqual(['step-1.user.name']); + }); + + it('should suggest a structurally correct path when the prefix is wrong (strategy B)', () => { + expect( + getVariablePathSuggestions({ + schema, + propertyPath: ['name'], + referencedStepId: 'step-1', + }), + ).toContain('step-1.user.name'); + }); + + it('should return no suggestions for an unrelated segment', () => { + expect( + getVariablePathSuggestions({ + schema, + propertyPath: ['completelyDifferentThing'], + referencedStepId: 'step-1', + }), + ).toEqual([]); + }); + + it('should return no suggestions when the path resolves', () => { + expect( + getVariablePathSuggestions({ + schema, + propertyPath: ['user', 'name'], + referencedStepId: 'step-1', + }), + ).toEqual([]); + }); + + it('should suggest record field names (not internal keys) for FIND_RECORDS schemas', () => { + const findRecordsSchema = { + first: { + isLeaf: false, + label: 'First', + value: { + object: { + objectMetadataId: 'company-metadata-id', + label: 'Company', + }, + fields: { + name: { + isLeaf: true, + type: 'TEXT', + label: 'Company Name', + value: 'Acme Corp', + fieldMetadataId: 'company-name-metadata-id', + isCompositeSubField: false, + }, + revenue: { + isLeaf: true, + type: 'NUMBER', + label: 'Revenue', + value: 1000000, + fieldMetadataId: 'company-revenue-metadata-id', + isCompositeSubField: false, + }, + }, + _outputSchemaType: 'RECORD', + }, + }, + all: { + isLeaf: true, + label: 'All', + value: 'Returns an array of records', + type: 'array', + }, + totalCount: { + isLeaf: true, + label: 'Total Count', + value: 42, + type: 'number', + }, + }; + + const suggestions = getVariablePathSuggestions({ + schema: findRecordsSchema, + propertyPath: ['first', 'naem'], + referencedStepId: 'step-1', + }); + + expect(suggestions).toContain('step-1.first.name'); + expect( + suggestions.some( + (suggestion) => + suggestion.includes('object') || + suggestion.includes('fields') || + suggestion.includes('_outputSchemaType'), + ), + ).toBe(false); + }); + + it('should suggest record field names (not internal keys) for FORM schemas', () => { + const formSchema = { + companyName: { + isLeaf: true, + type: 'TEXT', + label: 'Company Name', + value: 'Acme Corp', + }, + person: { + isLeaf: false, + label: 'Person', + value: { + object: { + objectMetadataId: 'person-metadata-id', + label: 'Person', + }, + fields: { + firstName: { + isLeaf: true, + type: 'TEXT', + label: 'First Name', + value: 'John', + fieldMetadataId: 'person-firstName-metadata-id', + isCompositeSubField: false, + }, + }, + _outputSchemaType: 'RECORD', + }, + }, + }; + + const suggestions = getVariablePathSuggestions({ + schema: formSchema, + propertyPath: ['person', 'firstNaem'], + referencedStepId: 'step-1', + }); + + expect(suggestions).toContain('step-1.person.firstName'); + expect( + suggestions.some( + (suggestion) => + suggestion.includes('object') || + suggestion.includes('fields') || + suggestion.includes('_outputSchemaType'), + ), + ).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-graph.util.test.ts b/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-graph.util.test.ts new file mode 100644 index 00000000000..b43a170c020 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-graph.util.test.ts @@ -0,0 +1,121 @@ +import { WorkflowActionType } from '@/workflow/types/WorkflowActionType'; +import { + type ValidatableWorkflow, + type WorkflowValidationIssueCode, +} from '@/workflow/validation/types/workflow-validation.type'; +import { buildWorkflowGraph } from '../build-workflow-graph.util'; +import { validateWorkflowGraph } from '../validate-workflow-graph.util'; + +const getCodes = ( + workflow: ValidatableWorkflow, +): WorkflowValidationIssueCode[] => + validateWorkflowGraph({ + workflow, + graph: buildWorkflowGraph(workflow), + }).map((issue) => issue.code); + +describe('validateWorkflowGraph', () => { + it('should return no issues for a valid linear workflow', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [{ id: 's1', type: 'CODE', nextStepIds: [] }], + }; + + expect(getCodes(workflow)).toEqual([]); + }); + + it('should flag duplicate step ids', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [ + { id: 's1', type: 'CODE' }, + { id: 's1', type: 'CODE' }, + ], + }; + + expect(getCodes(workflow)).toContain('DUPLICATE_STEP_ID'); + }); + + it('should flag a trigger with no connected step', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL' }, + steps: [{ id: 's1', type: 'CODE' }], + }; + + expect(getCodes(workflow)).toContain('TRIGGER_HAS_NO_NEXT_STEP'); + }); + + it('should flag a dangling trigger reference', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['ghost'] }, + steps: [{ id: 's1', type: 'CODE' }], + }; + + expect(getCodes(workflow)).toContain('DANGLING_REFERENCE'); + }); + + it('should flag unreachable steps', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [ + { id: 's1', type: 'CODE' }, + { id: 'orphan', type: 'CODE' }, + ], + }; + + expect(getCodes(workflow)).toContain('UNREACHABLE_STEP'); + }); + + it('should flag an if-else step with fewer than two branches', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['if'] }, + steps: [ + { + id: 'if', + type: WorkflowActionType.IF_ELSE, + settings: { input: { branches: [{ nextStepIds: ['end'] }] } }, + }, + { id: 'end', type: 'CODE' }, + ], + }; + + expect(getCodes(workflow)).toContain('IF_ELSE_INSUFFICIENT_BRANCHES'); + }); + + it('should flag an if-else branch with no connected step', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['if'] }, + steps: [ + { + id: 'if', + type: WorkflowActionType.IF_ELSE, + settings: { + input: { + branches: [{ nextStepIds: ['end'] }, { nextStepIds: [] }], + }, + }, + }, + { id: 'end', type: 'CODE' }, + ], + }; + + expect(getCodes(workflow)).toContain('IF_ELSE_BRANCH_HAS_NO_NEXT_STEP'); + }); + + it('should flag an iterator with items but no loop body', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['iterator'] }, + steps: [ + { + id: 'iterator', + type: WorkflowActionType.ITERATOR, + settings: { + input: { items: '{{trigger.items}}', initialLoopStepIds: [] }, + }, + }, + ], + }; + + expect(getCodes(workflow)).toContain('ITERATOR_MISSING_LOOP_BODY'); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-step-params.util.test.ts b/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-step-params.util.test.ts new file mode 100644 index 00000000000..17e67740dea --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-step-params.util.test.ts @@ -0,0 +1,42 @@ +import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type'; +import { validateWorkflowStepParams } from '../validate-workflow-step-params.util'; + +describe('validateWorkflowStepParams', () => { + it('should return no issues when there is no trigger and no steps', () => { + const workflow: ValidatableWorkflow = { + trigger: undefined, + steps: undefined, + }; + + expect(validateWorkflowStepParams(workflow)).toEqual([]); + }); + + it('should flag an invalid trigger configuration', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'NOT_A_REAL_TRIGGER' }, + steps: [], + }; + + const issues = validateWorkflowStepParams(workflow); + + expect( + issues.some((issue) => issue.code === 'INVALID_TRIGGER_PARAMS'), + ).toBe(true); + }); + + it('should flag an invalid step configuration with its step id', () => { + const workflow: ValidatableWorkflow = { + trigger: undefined, + steps: [{ id: 'step-1', type: 'NOT_A_REAL_ACTION' }], + }; + + const issues = validateWorkflowStepParams(workflow); + + expect( + issues.some( + (issue) => + issue.code === 'INVALID_STEP_PARAMS' && issue.stepId === 'step-1', + ), + ).toBe(true); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-variable-references.util.test.ts b/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-variable-references.util.test.ts new file mode 100644 index 00000000000..db3855adf47 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/__tests__/validate-workflow-variable-references.util.test.ts @@ -0,0 +1,184 @@ +import { + type ValidatableWorkflow, + type ValidatableWorkflowStep, + type WorkflowValidationIssueCode, +} from '@/workflow/validation/types/workflow-validation.type'; +import { buildWorkflowGraph } from '../build-workflow-graph.util'; +import { validateWorkflowVariableReferences } from '../validate-workflow-variable-references.util'; + +const OUTPUT_SCHEMA = { + name: { isLeaf: true, type: 'string', label: 'name', value: 'John' }, +}; + +const getCodes = ( + workflow: ValidatableWorkflow, +): WorkflowValidationIssueCode[] => { + const steps = workflow.steps ?? []; + + return validateWorkflowVariableReferences({ + workflow, + graph: buildWorkflowGraph(workflow), + stepsById: new Map( + steps.map((step) => [step.id, step]), + ), + }).map((issue) => issue.code); +}; + +describe('validateWorkflowVariableReferences', () => { + it('should return no issues for a valid upstream reference that resolves', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [ + { + id: 's1', + type: 'CODE', + nextStepIds: ['s2'], + settings: { outputSchema: OUTPUT_SCHEMA }, + }, + { + id: 's2', + type: 'CODE', + settings: { input: { value: '{{s1.name}}' } }, + }, + ], + }; + + expect(getCodes(workflow)).toEqual([]); + }); + + it('should flag a variable with an invalid path', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [ + { id: 's1', type: 'CODE', settings: { input: { value: '{{.}}' } } }, + ], + }; + + expect(getCodes(workflow)).toContain('VARIABLE_INVALID_PATH'); + }); + + it('should flag a reference to an unknown step', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [ + { + id: 's1', + type: 'CODE', + settings: { input: { value: '{{ghost.name}}' } }, + }, + ], + }; + + expect(getCodes(workflow)).toContain('VARIABLE_UNKNOWN_STEP'); + }); + + it('should flag a reference to a step that does not run before it', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1', 's2'] }, + steps: [ + { + id: 's1', + type: 'CODE', + settings: { input: { value: '{{s2.name}}' } }, + }, + { id: 's2', type: 'CODE', settings: { outputSchema: OUTPUT_SCHEMA } }, + ], + }; + + expect(getCodes(workflow)).toContain('VARIABLE_NOT_UPSTREAM'); + }); + + it('should flag an upstream reference whose path is not found in the output schema', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [ + { + id: 's1', + type: 'CODE', + nextStepIds: ['s2'], + settings: { outputSchema: OUTPUT_SCHEMA }, + }, + { + id: 's2', + type: 'CODE', + settings: { input: { value: '{{s1.unknownField}}' } }, + }, + ], + }; + + expect(getCodes(workflow)).toContain('VARIABLE_PATH_NOT_FOUND'); + }); + + it('should resolve a valid trigger reference against the trigger output schema', () => { + const workflow: ValidatableWorkflow = { + trigger: { + type: 'MANUAL', + nextStepIds: ['s1'], + settings: { outputSchema: OUTPUT_SCHEMA }, + }, + steps: [ + { + id: 's1', + type: 'CODE', + settings: { input: { value: '{{trigger.name}}' } }, + }, + ], + }; + + expect(getCodes(workflow)).toEqual([]); + }); + + it('should flag an invalid path in the trigger output schema', () => { + const workflow: ValidatableWorkflow = { + trigger: { + type: 'MANUAL', + nextStepIds: ['s1'], + settings: { outputSchema: OUTPUT_SCHEMA }, + }, + steps: [ + { + id: 's1', + type: 'CODE', + settings: { input: { value: '{{trigger.unknownField}}' } }, + }, + ], + }; + + expect(getCodes(workflow)).toContain('VARIABLE_PATH_NOT_FOUND'); + }); + + it('should not flag a self-reference as not-upstream', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL', nextStepIds: ['s1'] }, + steps: [ + { + id: 's1', + type: 'CODE', + settings: { + input: { value: '{{s1.name}}' }, + outputSchema: OUTPUT_SCHEMA, + }, + }, + ], + }; + + expect(getCodes(workflow)).not.toContain('VARIABLE_NOT_UPSTREAM'); + }); + + // Regression: a trigger reference must never be flagged as not-upstream, even + // when the trigger has no outgoing connections (so it is not in any ancestor set). + it('should not flag a trigger reference as not-upstream when the trigger is disconnected', () => { + const workflow: ValidatableWorkflow = { + trigger: { type: 'MANUAL' }, + steps: [ + { + id: 's1', + type: 'CODE', + settings: { input: { value: '{{trigger.foo}}' } }, + }, + ], + }; + + expect(getCodes(workflow)).toEqual([]); + }); +}); diff --git a/packages/twenty-shared/src/workflow/validation/utils/build-workflow-graph.util.ts b/packages/twenty-shared/src/workflow/validation/utils/build-workflow-graph.util.ts new file mode 100644 index 00000000000..085844a0db5 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/build-workflow-graph.util.ts @@ -0,0 +1,97 @@ +import { isDefined } from '@/utils'; +import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId'; +import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type'; +import { getStepOutgoingStepIds } from '@/workflow/validation/utils/get-step-outgoing-step-ids.util'; + +export type WorkflowGraph = { + childrenByStepId: Map; + reachableFromTrigger: Set; + ancestorsByStepId: Map>; +}; + +export const buildWorkflowGraph = ({ + trigger, + steps, +}: ValidatableWorkflow): WorkflowGraph => { + const childrenByStepId = new Map(); + + const triggerNextStepIds = isDefined(trigger?.nextStepIds) + ? trigger.nextStepIds.filter(isDefined) + : []; + + childrenByStepId.set(TRIGGER_STEP_ID, triggerNextStepIds); + + for (const step of steps ?? []) { + childrenByStepId.set(step.id, getStepOutgoingStepIds(step)); + } + + const reachableFromTrigger = new Set(); + const queue: string[] = [TRIGGER_STEP_ID]; + + while (queue.length > 0) { + const currentStepId = queue.shift(); + + if (!isDefined(currentStepId) || reachableFromTrigger.has(currentStepId)) { + continue; + } + + reachableFromTrigger.add(currentStepId); + + for (const nextStepId of childrenByStepId.get(currentStepId) ?? []) { + if (!reachableFromTrigger.has(nextStepId)) { + queue.push(nextStepId); + } + } + } + + const ancestorsByStepId = computeAncestors(childrenByStepId); + + return { childrenByStepId, reachableFromTrigger, ancestorsByStepId }; +}; + +const computeAncestors = ( + childrenByStepId: Map, +): Map> => { + const parentsByStepId = new Map>(); + + for (const [stepId, nextStepIds] of childrenByStepId.entries()) { + for (const nextStepId of nextStepIds) { + const parents = parentsByStepId.get(nextStepId) ?? new Set(); + + parents.add(stepId); + parentsByStepId.set(nextStepId, parents); + } + } + + const ancestorsByStepId = new Map>(); + + for (const stepId of childrenByStepId.keys()) { + if (ancestorsByStepId.has(stepId)) { + continue; + } + + const ancestors = new Set(); + const queue = [...(parentsByStepId.get(stepId) ?? [])]; + + while (queue.length > 0) { + const ancestorStepId = queue.shift()!; + + if (ancestors.has(ancestorStepId)) { + continue; + } + + ancestors.add(ancestorStepId); + + for (const grandParentStepId of parentsByStepId.get(ancestorStepId) ?? + []) { + if (!ancestors.has(grandParentStepId)) { + queue.push(grandParentStepId); + } + } + } + + ancestorsByStepId.set(stepId, ancestors); + } + + return ancestorsByStepId; +}; diff --git a/packages/twenty-shared/src/workflow/validation/utils/extract-variables-from-input.util.ts b/packages/twenty-shared/src/workflow/validation/utils/extract-variables-from-input.util.ts new file mode 100644 index 00000000000..55ce30caf1e --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/extract-variables-from-input.util.ts @@ -0,0 +1,25 @@ +import { isObject, isString } from '@sniptt/guards'; + +import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/constants/CaptureAllVariableTagInnerRegex'; + +function* resolveVariables(value: unknown): Generator { + if (isString(value)) { + for (const [, variablePath] of value.matchAll( + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + )) { + yield variablePath; + } + + return; + } + + if (isObject(value)) { + for (const nestedValue of Object.values(value)) { + yield* resolveVariables(nestedValue); + } + } +} + +export const extractVariablesFromInput = (input: unknown): string[] => { + return [...resolveVariables(input)]; +}; diff --git a/packages/twenty-shared/src/workflow/validation/utils/get-edit-distance.util.ts b/packages/twenty-shared/src/workflow/validation/utils/get-edit-distance.util.ts new file mode 100644 index 00000000000..b49eb9f5f34 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/get-edit-distance.util.ts @@ -0,0 +1,32 @@ +// Levenshtein edit distance +export const getEditDistance = (source: string, target: string): number => { + const rowCount = source.length + 1; + const columnCount = target.length + 1; + + const matrix: number[][] = Array.from({ length: rowCount }, () => + new Array(columnCount).fill(0), + ); + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + matrix[rowIndex][0] = rowIndex; + } + + for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { + matrix[0][columnIndex] = columnIndex; + } + + for (let rowIndex = 1; rowIndex < rowCount; rowIndex++) { + for (let columnIndex = 1; columnIndex < columnCount; columnIndex++) { + const substitutionCost = + source[rowIndex - 1] === target[columnIndex - 1] ? 0 : 1; + + matrix[rowIndex][columnIndex] = Math.min( + matrix[rowIndex - 1][columnIndex] + 1, + matrix[rowIndex][columnIndex - 1] + 1, + matrix[rowIndex - 1][columnIndex - 1] + substitutionCost, + ); + } + } + + return matrix[source.length][target.length]; +}; diff --git a/packages/twenty-shared/src/workflow/validation/utils/get-step-outgoing-step-ids.util.ts b/packages/twenty-shared/src/workflow/validation/utils/get-step-outgoing-step-ids.util.ts new file mode 100644 index 00000000000..4ffadcc7bcd --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/get-step-outgoing-step-ids.util.ts @@ -0,0 +1,39 @@ +import { isDefined } from '@/utils'; +import { isIfElseStepInput } from '@/workflow/validation/guards/isIfElseStepInput'; +import { isIteratorStepInput } from '@/workflow/validation/guards/isIteratorStepInput'; +import { type ValidatableWorkflowStep } from '@/workflow/validation/types/workflow-validation.type'; +import { isObject } from '@sniptt/guards'; + +export const getStepInput = ( + step: ValidatableWorkflowStep, +): Record | undefined => { + const input = step.settings?.input; + + if (isDefined(input) && isObject(input)) { + return input as Record; + } + + return undefined; +}; + +export const getStepOutgoingStepIds = ( + step: ValidatableWorkflowStep, +): string[] => { + const outgoingStepIds = new Set(step.nextStepIds ?? []); + + if (isIfElseStepInput(step)) { + for (const branch of step.settings.input.branches ?? []) { + for (const nextStepId of branch?.nextStepIds ?? []) { + outgoingStepIds.add(nextStepId); + } + } + } + + if (isIteratorStepInput(step)) { + for (const nextStepId of step.settings.input.initialLoopStepIds ?? []) { + outgoingStepIds.add(nextStepId); + } + } + + return [...outgoingStepIds]; +}; diff --git a/packages/twenty-shared/src/workflow/validation/utils/get-variable-path-suggestions.util.ts b/packages/twenty-shared/src/workflow/validation/utils/get-variable-path-suggestions.util.ts new file mode 100644 index 00000000000..468c182523e --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/get-variable-path-suggestions.util.ts @@ -0,0 +1,80 @@ +import { isNonEmptyArray } from '@sniptt/guards'; + +import { isDefined, isPlainObject } from '@/utils'; +import { getEditDistance } from '@/workflow/validation/utils/get-edit-distance.util'; +import { isBaseOutputSchemaV2 } from '@/workflow/workflow-schema/guards/isBaseOutputSchemaV2'; +import { collectOutputSchemaPaths } from '@/workflow/workflow-schema/utils/collect-output-schema-paths'; +import { findOutputSchemaPathFailure } from '@/workflow/workflow-schema/utils/find-output-schema-path-failure'; +import { collectOutputSchemaVariablePaths } from '@/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema'; + +const MAX_SUGGESTIONS = 3; + +const containsRecordOutputSchema = (value: unknown): boolean => { + if (!isPlainObject(value)) { + return false; + } + + if (value['_outputSchemaType'] === 'RECORD') { + return true; + } + + return Object.values(value).some( + (entry) => + isPlainObject(entry) && containsRecordOutputSchema(entry['value']), + ); +}; + +const rankClosestCandidates = ( + target: string, + candidates: string[], +): string[] => + candidates + .map((candidate) => ({ + candidate, + distance: getEditDistance(target, candidate), + })) + .filter( + ({ candidate, distance }) => distance <= Math.ceil(candidate.length / 2), + ) + .sort((a, b) => a.distance - b.distance) + .slice(0, MAX_SUGGESTIONS) + .map(({ candidate }) => candidate); + +export const getVariablePathSuggestions = ({ + schema, + propertyPath, + referencedStepId, +}: { + schema: unknown; + propertyPath: string[]; + referencedStepId: string; +}): string[] => { + if (!isBaseOutputSchemaV2(schema) || containsRecordOutputSchema(schema)) { + const allPaths = collectOutputSchemaVariablePaths(schema); + + return rankClosestCandidates(propertyPath.join('.'), allPaths).map((path) => + [referencedStepId, path].join('.'), + ); + } + + const failure = findOutputSchemaPathFailure({ schema, propertyPath }); + + if (!isDefined(failure)) { + return []; + } + + const localMatches = rankClosestCandidates( + failure.failedSegment, + failure.availableKeys, + ).map((key) => [referencedStepId, ...failure.validPrefix, key].join('.')); + + if (isNonEmptyArray(localMatches)) { + return localMatches; + } + + const allPaths = collectOutputSchemaPaths(schema); + + return rankClosestCandidates(propertyPath.join('.'), allPaths).map((path) => + [referencedStepId, path].join('.'), + ); +}; diff --git a/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-graph.util.ts b/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-graph.util.ts new file mode 100644 index 00000000000..b0782850fd6 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-graph.util.ts @@ -0,0 +1,144 @@ +import { isDefined } from '@/utils'; +import { WorkflowActionType } from '@/workflow/types/WorkflowActionType'; +import { + type IfElseStepInput, + type IteratorStepInput, + type ValidatableWorkflow, + type ValidatableWorkflowStep, + type WorkflowValidationIssue, +} from '@/workflow/validation/types/workflow-validation.type'; +import { type WorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util'; +import { getStepInput } from '@/workflow/validation/utils/get-step-outgoing-step-ids.util'; + +export const validateWorkflowGraph = ({ + workflow, + graph, +}: { + workflow: ValidatableWorkflow; + graph: WorkflowGraph; +}): WorkflowValidationIssue[] => { + const issues: WorkflowValidationIssue[] = []; + const steps = workflow.steps ?? []; + const stepIds = new Set(steps.map((step) => step.id)); + + const seenStepIds = new Set(); + + for (const step of steps) { + if (seenStepIds.has(step.id)) { + issues.push({ + severity: 'error', + code: 'DUPLICATE_STEP_ID', + message: `Duplicate step id "${step.id}". Every step id must be unique within the workflow.`, + stepId: step.id, + }); + } + seenStepIds.add(step.id); + } + + const triggerNextStepIds = (workflow.trigger?.nextStepIds ?? []).filter( + isDefined, + ); + + if (steps.length > 0 && triggerNextStepIds.length === 0) { + issues.push({ + severity: 'error', + code: 'TRIGGER_HAS_NO_NEXT_STEP', + message: `The trigger is not connected to any step. The trigger must have a "nextStepIds" array pointing to the first step (e.g. nextStepIds: ["${steps[0].id}"]). If you used edges, also set trigger.nextStepIds.`, + }); + } + + for (const triggerNextStepId of triggerNextStepIds) { + if (!stepIds.has(triggerNextStepId)) { + issues.push({ + severity: 'error', + code: 'DANGLING_REFERENCE', + message: `The trigger references a non-existent step "${triggerNextStepId}".`, + }); + } + } + + for (const step of steps) { + for (const outgoingStepId of graph.childrenByStepId.get(step.id) ?? []) { + if (!stepIds.has(outgoingStepId)) { + issues.push({ + severity: 'error', + code: 'DANGLING_REFERENCE', + message: `Step "${step.name ?? step.id}" references a non-existent step "${outgoingStepId}".`, + stepId: step.id, + }); + } + } + + issues.push(...validateBranchingStep(step)); + } + + for (const step of steps) { + if (!graph.reachableFromTrigger.has(step.id)) { + issues.push({ + severity: 'error', + code: 'UNREACHABLE_STEP', + message: `Step "${step.name ?? step.id}" is not reachable from the trigger. Ensure a chain of nextStepIds connects the trigger to this step. Check that the preceding step includes this step's id ("${step.id}") in its nextStepIds array.`, + stepId: step.id, + }); + } + } + + return issues; +}; + +const validateBranchingStep = ( + step: ValidatableWorkflowStep, +): WorkflowValidationIssue[] => { + const issues: WorkflowValidationIssue[] = []; + const input = getStepInput(step); + + if (step.type === WorkflowActionType.IF_ELSE) { + const branchList = (input as Partial | undefined) + ?.branches; + const branches = Array.isArray(branchList) ? branchList : []; + + if (branches.length < 2) { + issues.push({ + severity: 'error', + code: 'IF_ELSE_INSUFFICIENT_BRANCHES', + message: `If/Else step "${step.name ?? step.id}" must have at least two branches (a condition branch and an else branch).`, + stepId: step.id, + }); + } + + for (const branch of branches) { + const branchNextStepIds = branch?.nextStepIds; + + if (!Array.isArray(branchNextStepIds) || branchNextStepIds.length === 0) { + issues.push({ + severity: 'error', + code: 'IF_ELSE_BRANCH_HAS_NO_NEXT_STEP', + message: `A branch of If/Else step "${step.name ?? step.id}" is not connected to any step.`, + stepId: step.id, + }); + } + } + } + + if (step.type === WorkflowActionType.ITERATOR) { + const iteratorInput = input as Partial | undefined; + const items = iteratorInput?.items; + const hasConfiguredItems = + (typeof items === 'string' && items.length > 0) || + (Array.isArray(items) && items.length > 0); + const initialLoopStepIds = iteratorInput?.initialLoopStepIds; + const hasLoopBody = + Array.isArray(initialLoopStepIds) && initialLoopStepIds.length > 0; + + if (hasConfiguredItems && !hasLoopBody) { + issues.push({ + severity: 'error', + code: 'ITERATOR_MISSING_LOOP_BODY', + message: `Iterator step "${step.name ?? step.id}" has items to iterate over but no steps inside the loop.`, + stepId: step.id, + }); + } + } + + return issues; +}; diff --git a/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-step-params.util.ts b/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-step-params.util.ts new file mode 100644 index 00000000000..808048f1efa --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-step-params.util.ts @@ -0,0 +1,63 @@ +import { workflowActionSchema } from '@/workflow/schemas/workflow-action-schema'; +import { workflowTriggerSchema } from '@/workflow/schemas/workflow-trigger-schema'; +import { isDefined } from '@/utils'; +import { + type ValidatableWorkflow, + type WorkflowValidationIssue, +} from '@/workflow/validation/types/workflow-validation.type'; +import { type z } from 'zod'; + +const formatZodPath = (path: PropertyKey[]): string => + path.map((segment) => String(segment)).join('.'); + +const formatZodIssue = (issue: z.ZodIssue): string => { + const path = formatZodPath(issue.path); + const base = path.length > 0 ? `${path}: ${issue.message}` : issue.message; + + if (issue.code === 'invalid_type' && path.length > 0) { + return `${base}. Ensure the field "${path}" exists at the correct nesting level in the step object (not inside "input").`; + } + + return base; +}; + +const formatZodIssues = (zodError: z.ZodError): string[] => + zodError.issues.map(formatZodIssue); + +export const validateWorkflowStepParams = ({ + trigger, + steps, +}: ValidatableWorkflow): WorkflowValidationIssue[] => { + const issues: WorkflowValidationIssue[] = []; + + if (isDefined(trigger)) { + const triggerResult = workflowTriggerSchema.safeParse(trigger); + + if (!triggerResult.success) { + for (const message of formatZodIssues(triggerResult.error)) { + issues.push({ + severity: 'error', + code: 'INVALID_TRIGGER_PARAMS', + message: `Trigger configuration is invalid - ${message}`, + }); + } + } + } + + for (const step of steps ?? []) { + const stepResult = workflowActionSchema.safeParse(step); + + if (!stepResult.success) { + for (const message of formatZodIssues(stepResult.error)) { + issues.push({ + severity: 'error', + code: 'INVALID_STEP_PARAMS', + message: `Step "${step.name ?? step.id}" configuration is invalid - ${message}`, + stepId: step.id, + }); + } + } + } + + return issues; +}; diff --git a/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-variable-references.util.ts b/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-variable-references.util.ts new file mode 100644 index 00000000000..b91e8167d26 --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/utils/validate-workflow-variable-references.util.ts @@ -0,0 +1,194 @@ +import { isNonEmptyArray, isObject } from '@sniptt/guards'; + +import { isDefined } from '@/utils'; +import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId'; +import { parseVariablePath } from '@/workflow/utils/variable-path.util'; +import { + type ValidatableWorkflow, + type ValidatableWorkflowStep, + type WorkflowValidationIssue, +} from '@/workflow/validation/types/workflow-validation.type'; +import { type WorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util'; +import { extractVariablesFromInput } from '@/workflow/validation/utils/extract-variables-from-input.util'; +import { getVariablePathSuggestions } from '@/workflow/validation/utils/get-variable-path-suggestions.util'; +import { + collectOutputSchemaVariablePaths, + resolveVariablePathInOutputSchema, +} from '@/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema'; + +export const validateWorkflowVariableReferences = ({ + workflow, + graph, + stepsById, +}: { + workflow: ValidatableWorkflow; + graph: WorkflowGraph; + stepsById: Map; +}): WorkflowValidationIssue[] => { + const issues: WorkflowValidationIssue[] = []; + const stepIds = new Set(workflow.steps?.map((step) => step.id) ?? []); + + for (const step of workflow.steps ?? []) { + const variables = extractVariablesFromInput(step.settings?.input); + const ancestors = graph.ancestorsByStepId.get(step.id) ?? new Set(); + + for (const variable of variables) { + const pathSegments = parseVariablePath(variable); + const referencedStepId = pathSegments[0]; + + if (!isDefined(referencedStepId)) { + issues.push({ + severity: 'error', + code: 'VARIABLE_INVALID_PATH', + message: `Step "${step.name ?? step.id}" has a variable "{{${variable}}}" with an invalid path. Variable references must start with a step ID, e.g. "{{stepId.property}}".`, + stepId: step.id, + path: variable, + }); + continue; + } + + const isTriggerReference = referencedStepId === TRIGGER_STEP_ID; + + if (!isTriggerReference && !stepIds.has(referencedStepId)) { + issues.push({ + severity: 'error', + code: 'VARIABLE_UNKNOWN_STEP', + message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" from an unknown step "${referencedStepId}".`, + stepId: step.id, + path: variable, + }); + continue; + } + + const isSelfReference = referencedStepId === step.id; + + // The trigger always runs before every step, so a trigger reference is + // upstream by definition even when it is not present in the ancestor set. + if ( + !isTriggerReference && + !isSelfReference && + !ancestors.has(referencedStepId) + ) { + const referencedStep = stepsById.get(referencedStepId); + const referencedStepLabel = referencedStep?.name ?? referencedStepId; + + issues.push({ + severity: 'error', + code: 'VARIABLE_NOT_UPSTREAM', + message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" from step "${referencedStepLabel}", which does not run before it. Ensure step "${referencedStepLabel}" is an ancestor (connected via nextStepIds chain from the trigger, before this step).`, + stepId: step.id, + path: variable, + }); + continue; + } + + issues.push( + ...validateVariablePathAgainstOutputSchema({ + step, + variable, + pathSegments, + referencedStepId, + isTriggerReference, + trigger: workflow.trigger, + stepsById, + }), + ); + } + } + + return issues; +}; + +const validateVariablePathAgainstOutputSchema = ({ + step, + variable, + pathSegments, + referencedStepId, + isTriggerReference, + trigger, + stepsById, +}: { + step: ValidatableWorkflowStep; + variable: string; + pathSegments: string[]; + referencedStepId: string; + isTriggerReference: boolean; + trigger: ValidatableWorkflow['trigger']; + stepsById: Map; +}): WorkflowValidationIssue[] => { + const propertyPath = pathSegments.slice(1); + + if (propertyPath.length === 0) { + return []; + } + + const outputSchema = isTriggerReference + ? trigger?.settings?.outputSchema + : stepsById.get(referencedStepId)?.settings?.outputSchema; + + const isEmptyOutputSchema = + isDefined(outputSchema) && + isObject(outputSchema) && + !Array.isArray(outputSchema) && + Object.keys(outputSchema).length === 0; + + if (!isDefined(outputSchema) || isEmptyOutputSchema) { + return []; + } + + const resolved = resolveVariablePathInOutputSchema({ + schema: outputSchema, + propertyPath, + }); + + if (!resolved.found) { + const suggestions = getVariablePathSuggestions({ + schema: outputSchema, + propertyPath, + referencedStepId, + }); + + const availablePaths = collectAvailablePaths( + outputSchema, + referencedStepId, + ); + + const hint = isNonEmptyArray(suggestions) + ? `Did you mean "{{${suggestions[0]}}}"?${ + suggestions.length > 1 + ? ` Other options: ${suggestions + .slice(1) + .map((suggestion) => `{{${suggestion}}}`) + .join(', ')}.` + : '' + }` + : isNonEmptyArray(availablePaths) + ? `Available paths: ${availablePaths.map((path) => `{{${path}}}`).join(', ')}.` + : undefined; + + return [ + { + severity: 'error', + code: 'VARIABLE_PATH_NOT_FOUND', + message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" but the path "${propertyPath.join('.')}" was not found in the output of step "${referencedStepId}".`, + stepId: step.id, + path: variable, + ...(isDefined(hint) ? { hint } : {}), + ...(isNonEmptyArray(suggestions) ? { suggestions } : {}), + ...(isNonEmptyArray(availablePaths) ? { availablePaths } : {}), + }, + ]; + } + + return []; +}; + +const MAX_AVAILABLE_PATHS = 20; + +const collectAvailablePaths = ( + outputSchema: unknown, + referencedStepId: string, +): string[] => + collectOutputSchemaVariablePaths(outputSchema) + .slice(0, MAX_AVAILABLE_PATHS) + .map((path) => `${referencedStepId}.${path}`); diff --git a/packages/twenty-shared/src/workflow/validation/validate-workflow-structure.util.ts b/packages/twenty-shared/src/workflow/validation/validate-workflow-structure.util.ts new file mode 100644 index 00000000000..22f697f527b --- /dev/null +++ b/packages/twenty-shared/src/workflow/validation/validate-workflow-structure.util.ts @@ -0,0 +1,74 @@ +import { isDefined } from '@/utils'; +import { + type ValidatableWorkflow, + type ValidatableWorkflowStep, + type WorkflowValidationIssue, + type WorkflowValidationResult, +} from '@/workflow/validation/types/workflow-validation.type'; +import { buildWorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util'; +import { validateWorkflowGraph } from '@/workflow/validation/utils/validate-workflow-graph.util'; +import { validateWorkflowStepParams } from '@/workflow/validation/utils/validate-workflow-step-params.util'; +import { validateWorkflowVariableReferences } from '@/workflow/validation/utils/validate-workflow-variable-references.util'; + +const buildResult = ( + issues: WorkflowValidationIssue[], +): WorkflowValidationResult => { + const errors = issues.filter((issue) => issue.severity === 'error'); + const warnings = issues.filter((issue) => issue.severity === 'warning'); + + return { + valid: errors.length === 0, + errors, + warnings, + }; +}; + +export const validateWorkflowStructure = ( + workflow: ValidatableWorkflow, +): WorkflowValidationResult => { + const issues: WorkflowValidationIssue[] = []; + + if (!isDefined(workflow.trigger)) { + issues.push({ + severity: 'error', + code: 'MISSING_TRIGGER', + message: 'The workflow has no trigger.', + }); + } else if (!isDefined(workflow.trigger.type)) { + issues.push({ + severity: 'error', + code: 'MISSING_TRIGGER_TYPE', + message: 'The workflow trigger has no type.', + }); + } + + const steps = workflow.steps ?? []; + + if (steps.length === 0) { + issues.push({ + severity: 'error', + code: 'NO_STEPS', + message: 'The workflow has no steps.', + }); + + return buildResult(issues); + } + + const stepsById = new Map( + steps.map((step) => [step.id, step]), + ); + + const graph = buildWorkflowGraph(workflow); + + issues.push(...validateWorkflowGraph({ workflow, graph })); + issues.push(...validateWorkflowStepParams(workflow)); + issues.push( + ...validateWorkflowVariableReferences({ + workflow, + graph, + stepsById, + }), + ); + + return buildResult(issues); +}; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/guards/isBaseOutputSchemaV2.ts b/packages/twenty-shared/src/workflow/workflow-schema/guards/isBaseOutputSchemaV2.ts new file mode 100644 index 00000000000..0da3afc7234 --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/guards/isBaseOutputSchemaV2.ts @@ -0,0 +1,24 @@ +import { isDefined } from '@/utils'; +import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type'; +import { isBoolean, isObject } from 'class-validator'; + +export const isBaseOutputSchemaV2 = ( + value: unknown, +): value is BaseOutputSchemaV2 => { + if (!isDefined(value) || !isObject(value) || Array.isArray(value)) { + return false; + } + + const entries = Object.values(value as Record); + + if (entries.length === 0) { + return false; + } + + return entries.every( + (entry) => + isDefined(entry) && + isObject(entry) && + isBoolean((entry as Record)['isLeaf']), + ); +}; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/index.ts b/packages/twenty-shared/src/workflow/workflow-schema/index.ts index 14955426536..d1f04dee483 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/index.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/index.ts @@ -1,3 +1,4 @@ +export { isBaseOutputSchemaV2 } from './guards/isBaseOutputSchemaV2'; export type { BaseOutputSchemaV2, Leaf, @@ -5,4 +6,36 @@ export type { Node, NodeType, } from './types/base-output-schema.type'; -export { navigateOutputSchemaProperty } from './utils/navigateOutputSchemaProperty'; +export { collectOutputSchemaPaths } from './utils/collect-output-schema-paths'; +export { + findOutputSchemaPathFailure, + type OutputSchemaPathFailure, +} from './utils/find-output-schema-path-failure'; +export { navigateOutputSchemaProperty } from './utils/navigate-output-schema-property'; +export { + collectOutputSchemaVariablePaths, + resolveVariablePathInOutputSchema, + type ResolvedVariable, +} from './utils/resolve-variable-path-in-output-schema'; +export type { + CodeOutputSchema, + FieldOutputSchemaV2, + FindRecordsOutputSchema, + FormFieldLeaf, + FormFieldNode, + FormOutputSchema, + IteratorOutputSchema, + LinkOutputSchema, + ManualTriggerOutputSchema, + OutputSchemaV2, + RecordFieldLeaf, + RecordFieldNode, + RecordFieldNodeValue, + RecordNode, + RecordOutputSchemaV2, + VariableSearchResult, +} from './types/output-schema.type'; +export { + searchRecordOutputSchema, + searchVariableInOutputSchema, +} from './utils/search-variable-in-output-schema'; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/types/output-schema.type.ts b/packages/twenty-shared/src/workflow/workflow-schema/types/output-schema.type.ts new file mode 100644 index 00000000000..ca997004204 --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/types/output-schema.type.ts @@ -0,0 +1,106 @@ +import { type FieldMetadataType } from '@/types/FieldMetadataType'; + +import { + type BaseOutputSchemaV2, + type Leaf, + type Node, +} from './base-output-schema.type'; + +export type RecordFieldLeaf = { + isLeaf: true; + icon?: string; + type: FieldMetadataType; + label: string; + value: any; + fieldMetadataId: string; + isCompositeSubField: boolean; +}; + +export type RecordFieldNode = { + isLeaf: false; + icon?: string; + type: FieldMetadataType; + label: string; + value: RecordFieldNodeValue; + fieldMetadataId: string; +}; + +export type RecordFieldNodeValue = + | RecordOutputSchemaV2 + | Record; + +export type FieldOutputSchemaV2 = RecordFieldLeaf | RecordFieldNode; + +export type RecordOutputSchemaV2 = { + object: { + icon?: string; + label: string; + objectMetadataId: string; + isRelationField?: boolean; + fieldIdName?: string; + }; + fields: Record; + _outputSchemaType: 'RECORD'; +}; + +export type RecordNode = { + isLeaf: false; + icon?: string; + label: string; + value: RecordOutputSchemaV2; +}; + +export type FindRecordsOutputSchema = { + first: RecordNode; + all: Leaf | undefined; + totalCount: Leaf; +}; + +export type IteratorOutputSchema = { + currentItem: RecordNode | Leaf | Node; + currentItemIndex: number; + hasProcessedAllItems: boolean; +}; + +export type FormFieldLeaf = { + isLeaf: true; + type: FieldMetadataType; + label: string; + value: unknown; +}; + +export type FormFieldNode = { + isLeaf: false; + label: string; + value: RecordOutputSchemaV2; +}; + +export type FormOutputSchema = Record; + +export type LinkOutputSchema = { + link: { isLeaf: true; tab?: string; label?: string }; + _outputSchemaType: 'LINK'; +}; + +export type CodeOutputSchema = LinkOutputSchema | BaseOutputSchemaV2; + +export type ManualTriggerOutputSchema = + | BaseOutputSchemaV2 + | RecordOutputSchemaV2; + +export type OutputSchemaV2 = + | BaseOutputSchemaV2 + | CodeOutputSchema + | FindRecordsOutputSchema + | FormOutputSchema + | RecordOutputSchemaV2 + | ManualTriggerOutputSchema + | IteratorOutputSchema; + +export type VariableSearchResult = { + variableLabel: string | undefined; + variablePathLabel: string | undefined; + variableType?: string; + fieldMetadataId?: string; + compositeFieldSubFieldName?: string; +}; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/collect-output-schema-paths.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/collect-output-schema-paths.test.ts new file mode 100644 index 00000000000..27519d65925 --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/collect-output-schema-paths.test.ts @@ -0,0 +1,60 @@ +import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type'; +import { collectOutputSchemaPaths } from '../collect-output-schema-paths'; + +describe('collectOutputSchemaPaths', () => { + const testSchema: BaseOutputSchemaV2 = { + user: { + isLeaf: false, + type: 'object', + label: 'user', + value: { + name: { + isLeaf: true, + type: 'string', + label: 'name', + value: 'Test', + }, + }, + }, + id: { + isLeaf: true, + type: 'string', + label: 'id', + value: '1', + }, + }; + + it('should enumerate both intermediate nodes and leaves', () => { + expect(collectOutputSchemaPaths(testSchema)).toEqual([ + 'user', + 'user.name', + 'id', + ]); + }); + + it('should return an empty array for an empty schema', () => { + expect(collectOutputSchemaPaths({})).toEqual([]); + }); + + it('should not throw when a non-leaf node has a null value', () => { + const schemaWithNullNode = { + data: { + isLeaf: false, + type: 'object', + label: 'Data', + value: null, + }, + id: { + isLeaf: true, + type: 'string', + label: 'id', + value: '1', + }, + } as unknown as BaseOutputSchemaV2; + + expect(collectOutputSchemaPaths(schemaWithNullNode)).toEqual([ + 'data', + 'id', + ]); + }); +}); diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/find-output-schema-path-failure.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/find-output-schema-path-failure.test.ts new file mode 100644 index 00000000000..94c399d3bdc --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/find-output-schema-path-failure.test.ts @@ -0,0 +1,102 @@ +import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type'; +import { findOutputSchemaPathFailure } from '../find-output-schema-path-failure'; + +describe('findOutputSchemaPathFailure', () => { + const testSchema: BaseOutputSchemaV2 = { + user: { + isLeaf: false, + type: 'object', + label: 'user', + value: { + name: { + isLeaf: true, + type: 'string', + label: 'name', + value: 'Test', + }, + email: { + isLeaf: true, + type: 'string', + label: 'email', + value: 'test@test.com', + }, + }, + }, + id: { + isLeaf: true, + type: 'string', + label: 'id', + value: '1', + }, + }; + + it('should return undefined when the path resolves fully', () => { + expect( + findOutputSchemaPathFailure({ + schema: testSchema, + propertyPath: ['user', 'name'], + }), + ).toBeUndefined(); + }); + + it('should report a failing top-level segment with its siblings', () => { + expect( + findOutputSchemaPathFailure({ + schema: testSchema, + propertyPath: ['usr'], + }), + ).toEqual({ + validPrefix: [], + failedSegment: 'usr', + availableKeys: ['user', 'id'], + }); + }); + + it('should report a failing nested segment with sibling keys at that level', () => { + expect( + findOutputSchemaPathFailure({ + schema: testSchema, + propertyPath: ['user', 'naem'], + }), + ).toEqual({ + validPrefix: ['user'], + failedSegment: 'naem', + availableKeys: ['name', 'email'], + }); + }); + + it('should report a failure when descending past a leaf', () => { + expect( + findOutputSchemaPathFailure({ + schema: testSchema, + propertyPath: ['id', 'deeper'], + }), + ).toEqual({ + validPrefix: ['id'], + failedSegment: 'deeper', + availableKeys: [], + }); + }); + + it('should not throw when descending into a non-leaf node with a null value', () => { + const schemaWithNullNode = { + data: { + isLeaf: false, + type: 'object', + label: 'Data', + value: null, + }, + } as unknown as BaseOutputSchemaV2; + + expect( + findOutputSchemaPathFailure({ + schema: schemaWithNullNode, + propertyPath: ['data', 'missingChild'], + }), + ).toEqual({ + validPrefix: ['data'], + failedSegment: 'missingChild', + availableKeys: [], + }); + }); +}); diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/navigateOutputSchemaProperty.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/navigate-output-schema-property.test.ts similarity index 97% rename from packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/navigateOutputSchemaProperty.test.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/navigate-output-schema-property.test.ts index 690a57dfcb4..5bf7f9b5658 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/navigateOutputSchemaProperty.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/navigate-output-schema-property.test.ts @@ -1,5 +1,5 @@ import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type'; -import { navigateOutputSchemaProperty } from '../navigateOutputSchemaProperty'; +import { navigateOutputSchemaProperty } from '../navigate-output-schema-property'; describe('navigateOutputSchemaProperty', () => { const testSchema: BaseOutputSchemaV2 = { diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/resolve-variable-path-in-output-schema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/resolve-variable-path-in-output-schema.test.ts new file mode 100644 index 00000000000..a8e483f6e17 --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/resolve-variable-path-in-output-schema.test.ts @@ -0,0 +1,206 @@ +import { resolveVariablePathInOutputSchema } from '../resolve-variable-path-in-output-schema'; + +const databaseEventTriggerSchema = { + object: { + label: 'Task', + objectMetadataId: 'object-1', + fieldIdName: 'properties.after.id', + }, + fields: { + 'properties.after.status': { + isLeaf: true, + type: 'SELECT', + label: 'Status', + value: 'My text', + fieldMetadataId: 'field-status', + }, + 'properties.after.name': { + isLeaf: false, + type: 'FULL_NAME', + label: 'Name', + fieldMetadataId: 'field-name', + value: { + firstName: { + isLeaf: true, + type: 'TEXT', + label: 'First Name', + value: 'Tim', + fieldMetadataId: 'field-name', + isCompositeSubField: true, + }, + }, + }, + }, + _outputSchemaType: 'RECORD', +}; + +describe('resolveVariablePathInOutputSchema', () => { + describe('database event record schema', () => { + it('should resolve a dotted event-prefixed field path', () => { + const result = resolveVariablePathInOutputSchema({ + schema: databaseEventTriggerSchema, + propertyPath: ['properties', 'after', 'status'], + }); + + expect(result.found).toBe(true); + expect(result.type).toBe('SELECT'); + expect(result.label).toBe('Status'); + }); + + it('should resolve a composite sub-field under an event prefix', () => { + const result = resolveVariablePathInOutputSchema({ + schema: databaseEventTriggerSchema, + propertyPath: ['properties', 'after', 'name', 'firstName'], + }); + + expect(result.found).toBe(true); + expect(result.type).toBe('TEXT'); + }); + + it('should not resolve an "object.*" path that does not match the real keys', () => { + const result = resolveVariablePathInOutputSchema({ + schema: databaseEventTriggerSchema, + propertyPath: ['object', 'status'], + }); + + expect(result.found).toBe(false); + }); + + it('should not resolve an unknown field', () => { + const result = resolveVariablePathInOutputSchema({ + schema: databaseEventTriggerSchema, + propertyPath: ['properties', 'after', 'statuss'], + }); + + expect(result.found).toBe(false); + }); + }); + + describe('base output schema', () => { + const baseSchema = { + success: { isLeaf: true, type: 'boolean', label: 'Success', value: true }, + }; + + it('should resolve a top-level leaf', () => { + expect( + resolveVariablePathInOutputSchema({ + schema: baseSchema, + propertyPath: ['success'], + }).found, + ).toBe(true); + }); + + it('should not resolve a missing leaf', () => { + expect( + resolveVariablePathInOutputSchema({ + schema: baseSchema, + propertyPath: ['failure'], + }).found, + ).toBe(false); + }); + }); + + describe('find records schema', () => { + const findRecordsSchema = { + first: { + isLeaf: false, + label: 'First Task', + value: { + object: { + label: 'Task', + objectMetadataId: 'object-1', + fieldIdName: 'id', + }, + fields: { + title: { + isLeaf: true, + type: 'TEXT', + label: 'Title', + value: 'My text', + fieldMetadataId: 'field-title', + }, + }, + _outputSchemaType: 'RECORD', + }, + }, + all: { + isLeaf: true, + type: 'array', + label: 'All Records', + value: 'Returns an array of records', + }, + totalCount: { + isLeaf: true, + type: 'number', + label: 'Total Count', + value: 'Count of matching records', + }, + }; + + it('should resolve a field under "first"', () => { + expect( + resolveVariablePathInOutputSchema({ + schema: findRecordsSchema, + propertyPath: ['first', 'title'], + }).found, + ).toBe(true); + }); + + it('should resolve the terminal "first" node', () => { + const result = resolveVariablePathInOutputSchema({ + schema: findRecordsSchema, + propertyPath: ['first'], + }); + + expect(result.found).toBe(true); + expect(result.label).toBe('First Task'); + }); + + it('should resolve totalCount', () => { + expect( + resolveVariablePathInOutputSchema({ + schema: findRecordsSchema, + propertyPath: ['totalCount'], + }).found, + ).toBe(true); + }); + + it('should not resolve a missing field under "first"', () => { + expect( + resolveVariablePathInOutputSchema({ + schema: findRecordsSchema, + propertyPath: ['first', 'missing'], + }).found, + ).toBe(false); + }); + }); + + describe('code step schema that mimics find records keys', () => { + // A CODE step can return an arbitrary object whose keys happen to match + // "first" and "totalCount". Because "first.value" is not a RECORD output + // schema, it must be treated as a generic map, not a Find Records schema. + const codeStepSchema = { + first: { isLeaf: true, type: 'string', label: 'First', value: 'a' }, + totalCount: { isLeaf: true, type: 'number', label: 'Count', value: 1 }, + foo: { isLeaf: true, type: 'string', label: 'Foo', value: 'bar' }, + }; + + it('should resolve the terminal "first" leaf', () => { + expect( + resolveVariablePathInOutputSchema({ + schema: codeStepSchema, + propertyPath: ['first'], + }).found, + ).toBe(true); + }); + + it('should resolve other top-level fields not handled by find records logic', () => { + expect( + resolveVariablePathInOutputSchema({ + schema: codeStepSchema, + propertyPath: ['foo'], + }).found, + ).toBe(true); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughBaseOutputSchema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.base.test.ts similarity index 92% rename from packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughBaseOutputSchema.test.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.base.test.ts index eccf087e1d2..3f28abebe1a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughBaseOutputSchema.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.base.test.ts @@ -1,7 +1,25 @@ -import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema'; -import type { BaseOutputSchemaV2 } from 'twenty-shared/workflow'; +import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type'; +import { searchVariableInOutputSchema } from '../search-variable-in-output-schema'; -describe('searchVariableThroughBaseOutputSchema', () => { +// Pins the dispatcher to a non-record step type so it resolves through the base schema branch +const searchVariableThroughBaseOutputSchema = ({ + stepName, + baseOutputSchema, + rawVariableName, +}: { + stepName: string; + baseOutputSchema: BaseOutputSchemaV2; + rawVariableName: string; +}) => + searchVariableInOutputSchema({ + schema: baseOutputSchema, + stepType: 'CODE', + stepName, + rawVariableName, + isFullRecord: false, + }); + +describe('searchVariableInOutputSchema - base output schema', () => { const mockBaseSchema: BaseOutputSchemaV2 = { message: { isLeaf: true, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughCodeOutputSchema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.code.test.ts similarity index 91% rename from packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughCodeOutputSchema.test.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.code.test.ts index 57b9318e49f..2a374efe5c5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughCodeOutputSchema.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.code.test.ts @@ -1,8 +1,25 @@ -import type { CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema'; -import { searchVariableThroughCodeOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema'; -import type { BaseOutputSchemaV2 } from 'twenty-shared/workflow'; +import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type'; +import { type CodeOutputSchema } from '../../types/output-schema.type'; +import { searchVariableInOutputSchema } from '../search-variable-in-output-schema'; -describe('searchVariableThroughCodeOutputSchema', () => { +const searchVariableThroughCodeOutputSchema = ({ + stepName, + codeOutputSchema, + rawVariableName, +}: { + stepName: string; + codeOutputSchema: CodeOutputSchema; + rawVariableName: string; +}) => + searchVariableInOutputSchema({ + schema: codeOutputSchema, + stepType: 'CODE', + stepName, + rawVariableName, + isFullRecord: false, + }); + +describe('searchVariableInOutputSchema - code output schema', () => { describe('LinkOutputSchema tests', () => { const mockLinkSchema: CodeOutputSchema = { link: { diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFindRecordsOutputSchema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.find-records.test.ts similarity index 84% rename from packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFindRecordsOutputSchema.test.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.find-records.test.ts index c96bbabf330..016e62e61b4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFindRecordsOutputSchema.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.find-records.test.ts @@ -1,9 +1,30 @@ -import { type FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema'; -import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; -import { searchVariableThroughFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FieldMetadataType } from '@/types/FieldMetadataType'; +import { + type FindRecordsOutputSchema, + type RecordOutputSchemaV2, +} from '../../types/output-schema.type'; +import { searchVariableInOutputSchema } from '../search-variable-in-output-schema'; -describe('searchVariableThroughFindRecordsOutputSchema', () => { +const searchVariableThroughFindRecordsOutputSchema = ({ + stepName, + searchRecordOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + searchRecordOutputSchema: FindRecordsOutputSchema; + rawVariableName: string; + isFullRecord: boolean; +}) => + searchVariableInOutputSchema({ + schema: searchRecordOutputSchema, + stepType: 'FIND_RECORDS', + stepName, + rawVariableName, + isFullRecord, + }); + +describe('searchVariableInOutputSchema - find records output schema', () => { const mockRecordSchema: RecordOutputSchemaV2 = { object: { objectMetadataId: 'company-metadata-id', diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFormOutputSchema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.form.test.ts similarity index 91% rename from packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFormOutputSchema.test.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.form.test.ts index 45c1650c8fd..274e2c41a38 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFormOutputSchema.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.form.test.ts @@ -1,9 +1,30 @@ -import { type FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema'; -import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; -import { searchVariableThroughFormOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FieldMetadataType } from '@/types/FieldMetadataType'; +import { + type FormOutputSchema, + type RecordOutputSchemaV2, +} from '../../types/output-schema.type'; +import { searchVariableInOutputSchema } from '../search-variable-in-output-schema'; -describe('searchVariableThroughFormOutputSchema', () => { +const searchVariableThroughFormOutputSchema = ({ + stepName, + formOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + formOutputSchema: FormOutputSchema; + rawVariableName: string; + isFullRecord: boolean; +}) => + searchVariableInOutputSchema({ + schema: formOutputSchema, + stepType: 'FORM', + stepName, + rawVariableName, + isFullRecord, + }); + +describe('searchVariableInOutputSchema - form output schema', () => { const mockRecordSchema: RecordOutputSchemaV2 = { object: { objectMetadataId: 'person-metadata-id', @@ -233,7 +254,6 @@ describe('searchVariableThroughFormOutputSchema', () => { }); it('should handle complex nested path correctly', () => { - // Test with a deeper path in case we have complex nested records const result = searchVariableThroughFormOutputSchema({ stepName: 'Company Form', formOutputSchema: mockFormSchema, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughIteratorOutputSchema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.iterator.test.ts similarity index 85% rename from packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughIteratorOutputSchema.test.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.iterator.test.ts index 29588cba8e3..a752219875a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughIteratorOutputSchema.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.iterator.test.ts @@ -1,9 +1,30 @@ -import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema'; -import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; -import { searchVariableThroughIteratorOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FieldMetadataType } from '@/types/FieldMetadataType'; +import { + type IteratorOutputSchema, + type RecordOutputSchemaV2, +} from '../../types/output-schema.type'; +import { searchVariableInOutputSchema } from '../search-variable-in-output-schema'; -describe('searchVariableThroughIteratorOutputSchema', () => { +const searchVariableThroughIteratorOutputSchema = ({ + stepName, + iteratorOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + iteratorOutputSchema: IteratorOutputSchema; + rawVariableName: string; + isFullRecord: boolean; +}) => + searchVariableInOutputSchema({ + schema: iteratorOutputSchema, + stepType: 'ITERATOR', + stepName, + rawVariableName, + isFullRecord, + }); + +describe('searchVariableInOutputSchema - iterator output schema', () => { const mockRecordSchema: RecordOutputSchemaV2 = { object: { objectMetadataId: 'company-metadata-id', diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordEventOutputSchema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.record-event.test.ts similarity index 95% rename from packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordEventOutputSchema.test.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.record-event.test.ts index e6d637c6ddb..88e43ad57f6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordEventOutputSchema.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.record-event.test.ts @@ -1,8 +1,27 @@ -import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; -import { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FieldMetadataType } from '@/types/FieldMetadataType'; +import { type RecordOutputSchemaV2 } from '../../types/output-schema.type'; +import { searchVariableInOutputSchema } from '../search-variable-in-output-schema'; -describe('searchVariableThroughRecordEventOutputSchema', () => { +const searchVariableThroughRecordEventOutputSchema = ({ + stepName, + recordOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + recordOutputSchema: RecordOutputSchemaV2; + rawVariableName: string; + isFullRecord: boolean; +}) => + searchVariableInOutputSchema({ + schema: recordOutputSchema, + stepType: 'DATABASE_EVENT', + stepName, + rawVariableName, + isFullRecord, + }); + +describe('searchVariableInOutputSchema - record event output schema', () => { const mockRecordSchema: RecordOutputSchemaV2 = { object: { objectMetadataId: 'company-metadata-id', @@ -466,7 +485,6 @@ describe('searchVariableThroughRecordEventOutputSchema', () => { isFullRecord: false, }); - // This should still work as the parser extracts what it can expect(result).toEqual({ variableLabel: undefined, variablePathLabel: undefined, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordOutputSchema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.record.test.ts similarity index 89% rename from packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordOutputSchema.test.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.record.test.ts index 3769f05d523..69cdf081377 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordOutputSchema.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.record.test.ts @@ -1,8 +1,27 @@ -import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; -import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FieldMetadataType } from '@/types/FieldMetadataType'; +import { type RecordOutputSchemaV2 } from '../../types/output-schema.type'; +import { searchVariableInOutputSchema } from '../search-variable-in-output-schema'; -describe('searchVariableThroughRecordOutputSchema', () => { +const searchVariableThroughRecordOutputSchema = ({ + stepName, + recordOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + recordOutputSchema: RecordOutputSchemaV2; + rawVariableName: string; + isFullRecord: boolean; +}) => + searchVariableInOutputSchema({ + schema: recordOutputSchema, + stepType: 'CREATE_RECORD', + stepName, + rawVariableName, + isFullRecord, + }); + +describe('searchVariableInOutputSchema - record output schema', () => { const mockRecordSchema: RecordOutputSchemaV2 = { object: { objectMetadataId: 'company-metadata-id', diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/collect-output-schema-paths.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/collect-output-schema-paths.ts new file mode 100644 index 00000000000..20222f5541a --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/collect-output-schema-paths.ts @@ -0,0 +1,30 @@ +import { isObject } from '@sniptt/guards'; + +import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type'; + +export const collectOutputSchemaPaths = ( + schema: BaseOutputSchemaV2, + prefix: string[] = [], +): string[] => { + const paths: string[] = []; + + if (!isObject(schema)) { + return paths; + } + + for (const [key, field] of Object.entries(schema)) { + if (!isObject(field)) { + continue; + } + + const currentPath = [...prefix, key]; + + paths.push(currentPath.join('.')); + + if (!field.isLeaf && isObject(field.value)) { + paths.push(...collectOutputSchemaPaths(field.value, currentPath)); + } + } + + return paths; +}; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/find-output-schema-path-failure.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/find-output-schema-path-failure.ts new file mode 100644 index 00000000000..157e4cf694e --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/find-output-schema-path-failure.ts @@ -0,0 +1,59 @@ +import { isObject } from '@sniptt/guards'; + +import { isDefined } from '@/utils'; +import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type'; + +export type OutputSchemaPathFailure = { + validPrefix: string[]; + failedSegment: string; + availableKeys: string[]; +}; + +export const findOutputSchemaPathFailure = ({ + schema, + propertyPath, +}: { + schema: BaseOutputSchemaV2; + propertyPath: string[]; +}): OutputSchemaPathFailure | undefined => { + let currentSchema: BaseOutputSchemaV2 = schema; + + for (let index = 0; index < propertyPath.length; index++) { + if (!isObject(currentSchema)) { + return { + validPrefix: propertyPath.slice(0, index), + failedSegment: propertyPath[index], + availableKeys: [], + }; + } + + const segment = propertyPath[index]; + const field = currentSchema[segment]; + + if (!isDefined(field)) { + return { + validPrefix: propertyPath.slice(0, index), + failedSegment: segment, + availableKeys: Object.keys(currentSchema), + }; + } + + if (field.isLeaf) { + const isLastSegment = index === propertyPath.length - 1; + + if (!isLastSegment) { + return { + validPrefix: propertyPath.slice(0, index + 1), + failedSegment: propertyPath[index + 1], + availableKeys: [], + }; + } + + return undefined; + } + + currentSchema = field.value; + } + + return undefined; +}; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/navigateOutputSchemaProperty.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/navigate-output-schema-property.ts similarity index 100% rename from packages/twenty-shared/src/workflow/workflow-schema/utils/navigateOutputSchemaProperty.ts rename to packages/twenty-shared/src/workflow/workflow-schema/utils/navigate-output-schema-property.ts diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema.ts new file mode 100644 index 00000000000..e5f44ebf106 --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema.ts @@ -0,0 +1,218 @@ +import { isDefined, isPlainObject } from '@/utils'; +import { isBoolean, isString } from 'class-validator'; + +export type ResolvedVariable = { + found: boolean; + type?: string; + label?: string; +}; + +const NOT_FOUND: ResolvedVariable = { found: false }; + +type SchemaField = { + isLeaf: boolean; + type?: string; + label?: string; + value?: unknown; +}; + +const isSchemaField = (value: unknown): value is SchemaField => + isPlainObject(value) && isBoolean(value.isLeaf); + +const isRecordOutputSchema = ( + value: unknown, +): value is { fields: Record } => + isPlainObject(value) && + value['_outputSchemaType'] === 'RECORD' && + isPlainObject(value['fields']); + +const isFindRecordsOutputSchema = ( + value: unknown, +): value is { first: SchemaField; all?: unknown; totalCount: SchemaField } => + isPlainObject(value) && + !('_outputSchemaType' in value) && + isSchemaField(value['first']) && + value['first'].isLeaf === false && + isRecordOutputSchema(value['first'].value) && + isSchemaField(value['totalCount']); + +const fieldResult = (field: SchemaField): ResolvedVariable => ({ + found: true, + type: isString(field.type) ? field.type : undefined, + label: isString(field.label) ? field.label : undefined, +}); + +const descendIntoField = ( + field: SchemaField, + segments: string[], +): ResolvedVariable => { + if (segments.length === 0) { + return fieldResult(field); + } + + return resolveInSchema(field.value, segments); +}; + +const resolveInFieldsMap = ( + fields: Record, + segments: string[], +): ResolvedVariable => { + for (let length = 1; length <= segments.length; length++) { + const candidateKey = segments.slice(0, length).join('.'); + const field = fields[candidateKey]; + + if (isSchemaField(field)) { + return descendIntoField(field, segments.slice(length)); + } + } + + return NOT_FOUND; +}; + +const resolveInFindRecords = ( + schema: { first: SchemaField; totalCount: unknown }, + segments: string[], +): ResolvedVariable => { + const [searchResultKey, ...rest] = segments; + + if (searchResultKey === 'first') { + if (rest.length === 0) { + return fieldResult(schema.first); + } + + return resolveInSchema(schema.first.value, rest); + } + + if (searchResultKey === 'all' || searchResultKey === 'totalCount') { + const field = (schema as Record)[searchResultKey]; + + if (rest.length === 0 && isSchemaField(field)) { + return fieldResult(field); + } + + return NOT_FOUND; + } + + return NOT_FOUND; +}; + +const resolveInGenericMap = ( + map: Record, + segments: string[], +): ResolvedVariable => { + const [segment, ...rest] = segments; + const field = map[segment]; + + if (isSchemaField(field)) { + return descendIntoField(field, rest); + } + + if (isDefined(field)) { + if (rest.length === 0) { + return { found: true }; + } + + if (isPlainObject(field)) { + return resolveInGenericMap(field, rest); + } + + return NOT_FOUND; + } + + return NOT_FOUND; +}; + +export const resolveInSchema = ( + schema: unknown, + segments: string[], +): ResolvedVariable => { + if (segments.length === 0 || !isPlainObject(schema)) { + return NOT_FOUND; + } + + if (isRecordOutputSchema(schema)) { + return resolveInFieldsMap(schema.fields, segments); + } + + if (isFindRecordsOutputSchema(schema)) { + return resolveInFindRecords(schema, segments); + } + + return resolveInGenericMap(schema, segments); +}; + +export const resolveVariablePathInOutputSchema = ({ + schema, + propertyPath, +}: { + schema: unknown; + propertyPath: string[]; +}): ResolvedVariable => resolveInSchema(schema, propertyPath); + +const collectFromFieldsMap = (fields: Record): string[] => { + const paths: string[] = []; + + for (const [key, field] of Object.entries(fields)) { + if (isSchemaField(field)) { + paths.push(key); + + if (field.isLeaf) { + continue; + } + + const value = field.value; + + if (isRecordOutputSchema(value)) { + for (const sub of collectFromFieldsMap(value.fields)) { + paths.push(`${key}.${sub}`); + } + } else if (isPlainObject(value)) { + for (const sub of collectFromFieldsMap(value)) { + paths.push(`${key}.${sub}`); + } + } + } else if (isDefined(field)) { + paths.push(key); + + if (isPlainObject(field)) { + for (const sub of collectFromFieldsMap(field)) { + paths.push(`${key}.${sub}`); + } + } + } + } + + return paths; +}; + +export const collectOutputSchemaVariablePaths = (schema: unknown): string[] => { + if (!isPlainObject(schema)) { + return []; + } + + if (isRecordOutputSchema(schema)) { + return collectFromFieldsMap(schema.fields); + } + + if (isFindRecordsOutputSchema(schema)) { + const paths: string[] = []; + + if (isSchemaField(schema.first)) { + paths.push('first'); + + for (const sub of collectOutputSchemaVariablePaths(schema.first.value)) { + paths.push(`first.${sub}`); + } + } + + if (isDefined(schema.all)) { + paths.push('all'); + } + + paths.push('totalCount'); + + return paths; + } + + return collectFromFieldsMap(schema); +}; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/search-variable-in-output-schema.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/search-variable-in-output-schema.ts new file mode 100644 index 00000000000..c16d614cc01 --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/search-variable-in-output-schema.ts @@ -0,0 +1,694 @@ +import { isDefined } from '@/utils'; +import { isObject } from 'class-validator'; +import { FieldMetadataType } from '@/types/FieldMetadataType'; + +import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '../../constants/CaptureAllVariableTagInnerRegex'; +import { parseVariablePath } from '../../utils/variable-path.util'; +import { type BaseOutputSchemaV2 } from '../types/base-output-schema.type'; +import { + type FieldOutputSchemaV2, + type FindRecordsOutputSchema, + type FormOutputSchema, + type IteratorOutputSchema, + type RecordFieldLeaf, + type RecordFieldNodeValue, + type RecordOutputSchemaV2, + type VariableSearchResult, +} from '../types/output-schema.type'; + +const EMPTY_RESULT: VariableSearchResult = { + variableLabel: undefined, + variablePathLabel: undefined, +}; + +const RECORD_STEP_TYPES = [ + 'CREATE_RECORD', + 'UPDATE_RECORD', + 'DELETE_RECORD', + 'UPSERT_RECORD', +]; + +const isRecordOutputSchemaV2 = ( + schema: unknown, +): schema is RecordOutputSchemaV2 => + isObject(schema) && + '_outputSchemaType' in schema && + schema._outputSchemaType === 'RECORD'; + +const isBaseOutputSchemaV2Shape = (schema: unknown): boolean => { + if (!isDefined(schema) || !isObject(schema) || Array.isArray(schema)) { + return false; + } + + return !(isObject(schema) && '_outputSchemaType' in schema); +}; + +const stripBrackets = (rawVariableName: string): string => + rawVariableName.replace( + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + (_, variableName) => variableName, + ); + +// Record output schema navigation + +const getFieldFromSchema = ( + fieldKey: string, + recordSchema: RecordFieldNodeValue, +): FieldOutputSchemaV2 | undefined => + isRecordOutputSchemaV2(recordSchema) + ? recordSchema.fields[fieldKey] + : (recordSchema as Record)[fieldKey]; + +const getCompositeSubFieldName = ( + recordSchema: RecordFieldNodeValue, + fieldKey: string, +): string | undefined => { + if (isRecordOutputSchemaV2(recordSchema)) { + return undefined; + } + + const field = (recordSchema as Record)[fieldKey]; + + return field?.isCompositeSubField ? fieldKey : undefined; +}; + +const isIdFieldName = (fieldName: string): boolean => + fieldName === 'id' || fieldName.endsWith('.id'); + +const navigateToTargetField = ( + startingSchema: RecordOutputSchemaV2, + pathSegments: string[], +): { schema: RecordFieldNodeValue; pathLabels: string[] } | null => { + let currentSchema: RecordFieldNodeValue = startingSchema; + const pathLabels: string[] = []; + + for (const pathSegment of pathSegments) { + const field = getFieldFromSchema(pathSegment, currentSchema); + + if (!isDefined(field)) { + return null; + } + + if (isDefined(field.label)) { + pathLabels.push(field.label); + } + + const nextSchema = field.value; + + if (!isDefined(nextSchema)) { + return null; + } + + currentSchema = nextSchema as RecordFieldNodeValue; + } + + return { schema: currentSchema, pathLabels }; +}; + +const buildRecordVariableResult = ( + stepName: string, + pathLabels: string[], + targetSchema: RecordFieldNodeValue, + targetFieldName: string, + isFullRecord: boolean, + stepNameLabel?: string, +): VariableSearchResult => { + const targetField = getFieldFromSchema(targetFieldName, targetSchema); + const variableLabel = + isFullRecord && + isRecordOutputSchemaV2(targetSchema) && + isIdFieldName(targetFieldName) + ? targetSchema.object.label + : targetField?.label; + + if (!variableLabel) { + return EMPTY_RESULT; + } + + const fullPathSegments = [stepName, ...pathLabels, variableLabel]; + const basePath = fullPathSegments.join(' > '); + const variablePathLabel = stepNameLabel + ? `${basePath} (${stepNameLabel})` + : basePath; + + return { + variableLabel, + variablePathLabel, + variableType: targetField?.type, + fieldMetadataId: targetField?.fieldMetadataId, + compositeFieldSubFieldName: getCompositeSubFieldName( + targetSchema, + targetFieldName, + ), + }; +}; + +export const searchRecordOutputSchema = ({ + stepName, + recordOutputSchema, + path, + selectedField, + isFullRecord, + stepNameLabel, +}: { + stepName: string; + recordOutputSchema: RecordOutputSchemaV2; + path: string[]; + selectedField: string; + isFullRecord: boolean; + stepNameLabel?: string; +}): VariableSearchResult => { + const navigationResult = navigateToTargetField(recordOutputSchema, path); + + if (!navigationResult) { + return EMPTY_RESULT; + } + + return buildRecordVariableResult( + stepName, + navigationResult.pathLabels, + navigationResult.schema, + selectedField, + isFullRecord, + stepNameLabel, + ); +}; + +// Base output schema navigation + +const navigateBaseToTargetField = ( + startingSchema: BaseOutputSchemaV2, + pathSegments: string[], +): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => { + let currentSchema: BaseOutputSchemaV2 = startingSchema; + const pathLabels: string[] = []; + + for (const pathSegment of pathSegments) { + const field = currentSchema[pathSegment]; + + if (!isDefined(field) || field.isLeaf === true) { + return null; + } + + if (!isDefined(field.value)) { + return null; + } + + pathLabels.push(field.label); + currentSchema = field.value; + } + + return { schema: currentSchema, pathLabels }; +}; + +const searchBaseOutputSchema = ({ + stepName, + baseOutputSchema, + path, + selectedField, +}: { + stepName: string; + baseOutputSchema: BaseOutputSchemaV2; + path: string[]; + selectedField: string; +}): VariableSearchResult => { + const navigationResult = navigateBaseToTargetField(baseOutputSchema, path); + + if (!navigationResult) { + return EMPTY_RESULT; + } + + const targetField = navigationResult.schema[selectedField]; + + if (!isDefined(targetField)) { + return EMPTY_RESULT; + } + + const fullPathSegments = [ + stepName, + ...navigationResult.pathLabels, + targetField.label, + ]; + const variablePathLabel = fullPathSegments.join(' > '); + + return { + variableLabel: targetField.label, + variablePathLabel, + variableType: targetField.type, + }; +}; + +// Per-schema-type search functions + +const searchThroughRecordOutputSchema = ({ + stepName, + recordOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + recordOutputSchema: RecordOutputSchemaV2; + rawVariableName: string; + isFullRecord: boolean; +}): VariableSearchResult => { + if (!isDefined(recordOutputSchema)) { + return EMPTY_RESULT; + } + + const parts = parseVariablePath(stripBrackets(rawVariableName)); + const stepId = parts[0]; + const fieldName = parts[parts.length - 1]; + const pathSegments = parts.slice(1, -1); + + if (!isDefined(stepId) || !isDefined(fieldName)) { + return EMPTY_RESULT; + } + + return searchRecordOutputSchema({ + stepName, + recordOutputSchema, + selectedField: fieldName, + path: pathSegments, + isFullRecord, + }); +}; + +const searchThroughRecordEventOutputSchema = ({ + stepName, + recordOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + recordOutputSchema: RecordOutputSchemaV2; + rawVariableName: string; + isFullRecord: boolean; +}): VariableSearchResult => { + if (!isDefined(recordOutputSchema)) { + return EMPTY_RESULT; + } + + const parts = parseVariablePath(stripBrackets(rawVariableName)); + const stepId = parts[0]; + const firstFieldWithEventPrefix = parts.slice(1, 4).join('.'); + const remainingParts = parts.slice(4); + const partsWithoutStepId = [firstFieldWithEventPrefix, ...remainingParts]; + const fieldName = partsWithoutStepId[partsWithoutStepId.length - 1]; + const pathSegments = partsWithoutStepId.slice(0, -1); + + if (!isDefined(stepId) || !isDefined(fieldName)) { + return EMPTY_RESULT; + } + + return searchRecordOutputSchema({ + stepName, + recordOutputSchema, + selectedField: fieldName, + path: pathSegments, + isFullRecord, + }); +}; + +const searchThroughBaseOutputSchema = ({ + stepName, + baseOutputSchema, + rawVariableName, +}: { + stepName: string; + baseOutputSchema: BaseOutputSchemaV2; + rawVariableName: string; +}): VariableSearchResult => { + if (!isDefined(baseOutputSchema)) { + return EMPTY_RESULT; + } + + const parts = parseVariablePath(stripBrackets(rawVariableName)); + const stepId = parts[0]; + const targetFieldName = parts[parts.length - 1]; + const pathSegments = parts.slice(1, -1); + + if (!isDefined(stepId) || !isDefined(targetFieldName)) { + return EMPTY_RESULT; + } + + return searchBaseOutputSchema({ + stepName, + baseOutputSchema, + path: pathSegments, + selectedField: targetFieldName, + }); +}; + +const searchThroughFindRecordsOutputSchema = ({ + stepName, + findRecordsOutputSchema, + rawVariableName, + isFullRecord, + stepNameLabel, +}: { + stepName: string; + findRecordsOutputSchema: FindRecordsOutputSchema; + rawVariableName: string; + isFullRecord: boolean; + stepNameLabel?: string; +}): VariableSearchResult => { + if (!isDefined(findRecordsOutputSchema)) { + return EMPTY_RESULT; + } + + const parts = parseVariablePath(stripBrackets(rawVariableName)); + const stepId = parts[0]; + const searchResultKey = parts[1] as 'first' | 'all' | 'totalCount'; + const remainingParts = parts.slice(2); + + if (!isDefined(stepId) || !isDefined(searchResultKey)) { + return EMPTY_RESULT; + } + + if (searchResultKey === 'first') { + const recordSchema = findRecordsOutputSchema.first?.value; + const fieldName = remainingParts[remainingParts.length - 1]; + const pathSegments = remainingParts.slice(0, -1); + + if (!isDefined(recordSchema) || !isDefined(fieldName)) { + return EMPTY_RESULT; + } + + return searchRecordOutputSchema({ + stepName: `${stepName} > ${findRecordsOutputSchema.first?.label ?? 'First'}`, + recordOutputSchema: recordSchema, + selectedField: fieldName, + path: pathSegments, + isFullRecord, + stepNameLabel, + }); + } + + if (searchResultKey === 'totalCount') { + const label = findRecordsOutputSchema.totalCount?.label ?? 'Total Count'; + const basePath = `${stepName} > ${label}`; + + return { + variableLabel: label, + variablePathLabel: stepNameLabel + ? `${basePath} (${stepNameLabel})` + : basePath, + variableType: FieldMetadataType.NUMBER, + }; + } + + if (searchResultKey === 'all') { + const allField = findRecordsOutputSchema.all; + const label = allField?.label ?? 'All Records'; + const basePath = `${stepName} > ${label}`; + + return { + variableLabel: label, + variablePathLabel: stepNameLabel + ? `${basePath} (${stepNameLabel})` + : basePath, + variableType: FieldMetadataType.ARRAY, + }; + } + + return EMPTY_RESULT; +}; + +const searchThroughFormOutputSchema = ({ + stepName, + formOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + formOutputSchema: FormOutputSchema; + rawVariableName: string; + isFullRecord: boolean; +}): VariableSearchResult => { + if (!isDefined(formOutputSchema)) { + return EMPTY_RESULT; + } + + const parts = parseVariablePath(stripBrackets(rawVariableName)); + const stepId = parts[0]; + const fieldName = parts[1]; + const remainingParts = parts.slice(2); + const recordFieldName = remainingParts[remainingParts.length - 1]; + const pathSegments = remainingParts.slice(0, -1); + + if (!isDefined(stepId) || !isDefined(fieldName)) { + return EMPTY_RESULT; + } + + const formField = formOutputSchema[fieldName]; + + if (!isDefined(formField)) { + return EMPTY_RESULT; + } + + if (formField.isLeaf) { + return { + variableLabel: formField.label, + variablePathLabel: `${stepName} > ${formField.label}`, + variableType: formField.type, + }; + } + + if (!formField.isLeaf && isDefined(recordFieldName)) { + return searchRecordOutputSchema({ + stepName: `${stepName} > ${formField.label}`, + recordOutputSchema: formField.value, + selectedField: recordFieldName, + path: pathSegments, + isFullRecord, + }); + } + + return EMPTY_RESULT; +}; + +const searchThroughCodeOutputSchema = ({ + stepName, + codeOutputSchema, + rawVariableName, +}: { + stepName: string; + codeOutputSchema: unknown; + rawVariableName: string; +}): VariableSearchResult => { + if (!isDefined(codeOutputSchema)) { + return EMPTY_RESULT; + } + + if ( + isObject(codeOutputSchema) && + '_outputSchemaType' in codeOutputSchema && + codeOutputSchema._outputSchemaType === 'LINK' + ) { + return EMPTY_RESULT; + } + + return searchThroughBaseOutputSchema({ + stepName, + baseOutputSchema: codeOutputSchema as BaseOutputSchemaV2, + rawVariableName, + }); +}; + +const searchThroughIteratorOutputSchema = ({ + stepName, + iteratorOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + iteratorOutputSchema: IteratorOutputSchema; + rawVariableName: string; + isFullRecord: boolean; +}): VariableSearchResult => { + if (!isDefined(iteratorOutputSchema)) { + return EMPTY_RESULT; + } + + const parts = parseVariablePath(stripBrackets(rawVariableName)); + const stepId = parts[0]; + const iteratorResultKey = parts[1] as + | 'currentItem' + | 'currentItemIndex' + | 'hasProcessedAllItems'; + const remainingParts = parts.slice(2); + + if (!isDefined(stepId) || !isDefined(iteratorResultKey)) { + return EMPTY_RESULT; + } + + if (iteratorResultKey === 'currentItemIndex') { + return { + variableLabel: 'Current Item Index', + variablePathLabel: `${stepName} > Current Item Index`, + variableType: FieldMetadataType.NUMBER, + }; + } + + if (iteratorResultKey === 'hasProcessedAllItems') { + return { + variableLabel: 'Has Processed All Items', + variablePathLabel: `${stepName} > Has Processed All Items`, + variableType: FieldMetadataType.BOOLEAN, + }; + } + + if (iteratorResultKey === 'currentItem') { + const schema = iteratorOutputSchema.currentItem.value; + + if (!isDefined(schema)) { + return EMPTY_RESULT; + } + + const fieldName = remainingParts[remainingParts.length - 1]; + const pathSegments = remainingParts.slice(0, -1); + + if (isRecordOutputSchemaV2(schema) && isDefined(fieldName)) { + return searchRecordOutputSchema({ + stepName: `${stepName} > Current Item`, + recordOutputSchema: schema, + path: pathSegments, + selectedField: fieldName, + isFullRecord, + }); + } + + if (isBaseOutputSchemaV2Shape(schema) && isDefined(fieldName)) { + return searchBaseOutputSchema({ + stepName, + baseOutputSchema: schema as BaseOutputSchemaV2, + path: pathSegments, + selectedField: fieldName, + }); + } + + const currentItem = iteratorOutputSchema.currentItem; + + return { + variableLabel: currentItem.label, + variablePathLabel: `${stepName} > ${currentItem.label}`, + variableType: currentItem.isLeaf ? currentItem.type : 'unknown', + }; + } + + return EMPTY_RESULT; +}; + +const searchThroughManualTriggerOutputSchema = ({ + stepName, + manualTriggerOutputSchema, + rawVariableName, + isFullRecord, +}: { + stepName: string; + manualTriggerOutputSchema: unknown; + rawVariableName: string; + isFullRecord: boolean; +}): VariableSearchResult => { + if (isRecordOutputSchemaV2(manualTriggerOutputSchema)) { + return searchThroughRecordOutputSchema({ + stepName, + recordOutputSchema: manualTriggerOutputSchema, + rawVariableName, + isFullRecord, + }); + } + + return searchThroughBaseOutputSchema({ + stepName, + baseOutputSchema: manualTriggerOutputSchema as BaseOutputSchemaV2, + rawVariableName, + }); +}; + +// Main dispatcher + +export const searchVariableInOutputSchema = ({ + schema, + stepType, + stepName, + rawVariableName, + isFullRecord, + stepNameLabel, +}: { + schema: unknown; + stepType: string; + stepName: string; + rawVariableName: string; + isFullRecord: boolean; + stepNameLabel?: string; +}): VariableSearchResult => { + if (RECORD_STEP_TYPES.includes(stepType)) { + return searchThroughRecordOutputSchema({ + stepName, + recordOutputSchema: schema as RecordOutputSchemaV2, + rawVariableName, + isFullRecord, + }); + } + + if (stepType === 'MANUAL') { + return searchThroughManualTriggerOutputSchema({ + stepName, + manualTriggerOutputSchema: schema, + rawVariableName, + isFullRecord, + }); + } + + if (stepType === 'DATABASE_EVENT') { + return searchThroughRecordEventOutputSchema({ + stepName, + recordOutputSchema: schema as RecordOutputSchemaV2, + rawVariableName, + isFullRecord, + }); + } + + if (stepType === 'FIND_RECORDS') { + return searchThroughFindRecordsOutputSchema({ + stepName, + findRecordsOutputSchema: schema as FindRecordsOutputSchema, + rawVariableName, + isFullRecord, + stepNameLabel, + }); + } + + if (stepType === 'FORM') { + return searchThroughFormOutputSchema({ + stepName, + formOutputSchema: schema as FormOutputSchema, + rawVariableName, + isFullRecord, + }); + } + + if (stepType === 'CODE') { + return searchThroughCodeOutputSchema({ + stepName, + codeOutputSchema: schema, + rawVariableName, + }); + } + + if (stepType === 'ITERATOR') { + return searchThroughIteratorOutputSchema({ + stepName, + iteratorOutputSchema: schema as IteratorOutputSchema, + rawVariableName, + isFullRecord, + }); + } + + return searchThroughBaseOutputSchema({ + stepName, + baseOutputSchema: schema as BaseOutputSchemaV2, + rawVariableName, + }); +};