diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 05ad4bcb323..0be195a7702 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -873,6 +873,8 @@ export type CreateWorkflowVersionEdgeInput = { }; export type CreateWorkflowVersionStepInput = { + /** Step ID */ + id?: InputMaybe; /** Next step ID */ nextStepId?: InputMaybe; /** Parent step connection options */ @@ -4033,10 +4035,8 @@ export type WorkflowVersion = { export type WorkflowVersionStepChanges = { __typename?: 'WorkflowVersionStepChanges'; - createdStep?: Maybe; - deletedStepIds?: Maybe>; - stepsNextStepIds?: Maybe; - triggerNextStepIds?: Maybe>; + stepsDiff?: Maybe; + triggerDiff?: Maybe; }; export type Workspace = { @@ -5411,6 +5411,8 @@ export type FindOneCoreViewSortQueryVariables = Exact<{ export type FindOneCoreViewSortQuery = { __typename?: 'Query', getCoreViewSort?: { __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string } | null }; +export type WorkflowDiffFragmentFragment = { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null }; + export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['UUID']; }>; @@ -5437,14 +5439,14 @@ export type CreateWorkflowVersionEdgeMutationVariables = Exact<{ }>; -export type CreateWorkflowVersionEdgeMutation = { __typename?: 'Mutation', createWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null } }; +export type CreateWorkflowVersionEdgeMutation = { __typename?: 'Mutation', createWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } }; export type CreateWorkflowVersionStepMutationVariables = Exact<{ input: CreateWorkflowVersionStepInput; }>; -export type CreateWorkflowVersionStepMutation = { __typename?: 'Mutation', createWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null, createdStep?: { __typename?: 'WorkflowAction', id: string, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } | null } }; +export type CreateWorkflowVersionStepMutation = { __typename?: 'Mutation', createWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } }; export type DeactivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['UUID']; @@ -5458,21 +5460,21 @@ export type DeleteWorkflowVersionEdgeMutationVariables = Exact<{ }>; -export type DeleteWorkflowVersionEdgeMutation = { __typename?: 'Mutation', deleteWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null } }; +export type DeleteWorkflowVersionEdgeMutation = { __typename?: 'Mutation', deleteWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } }; export type DeleteWorkflowVersionStepMutationVariables = Exact<{ input: DeleteWorkflowVersionStepInput; }>; -export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null, deletedStepIds?: Array | null } }; +export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } }; export type DuplicateWorkflowVersionStepMutationVariables = Exact<{ input: DuplicateWorkflowVersionStepInput; }>; -export type DuplicateWorkflowVersionStepMutation = { __typename?: 'Mutation', duplicateWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null, createdStep?: { __typename?: 'WorkflowAction', id: string, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } | null } }; +export type DuplicateWorkflowVersionStepMutation = { __typename?: 'Mutation', duplicateWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } }; export type RunWorkflowVersionMutationVariables = Exact<{ input: RunWorkflowVersionInput; @@ -6062,6 +6064,12 @@ ${BillingSubscriptionFragmentFragmentDoc} ${RoleFragmentFragmentDoc} ${ViewFragmentFragmentDoc} ${AvailableWorkspacesFragmentFragmentDoc}`; +export const WorkflowDiffFragmentFragmentDoc = gql` + fragment WorkflowDiffFragment on WorkflowVersionStepChanges { + triggerDiff + stepsDiff +} + `; export const AssignRoleToAgentDocument = gql` mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) { assignRoleToAgent(agentId: $agentId, roleId: $roleId) @@ -12394,11 +12402,10 @@ export type CreateDraftFromWorkflowVersionMutationOptions = Apollo.BaseMutationO export const CreateWorkflowVersionEdgeDocument = gql` mutation CreateWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) { createWorkflowVersionEdge(input: $input) { - triggerNextStepIds - stepsNextStepIds + ...WorkflowDiffFragment } } - `; + ${WorkflowDiffFragmentFragmentDoc}`; export type CreateWorkflowVersionEdgeMutationFn = Apollo.MutationFunction; /** @@ -12428,23 +12435,10 @@ export type CreateWorkflowVersionEdgeMutationOptions = Apollo.BaseMutationOption export const CreateWorkflowVersionStepDocument = gql` mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) { createWorkflowVersionStep(input: $input) { - triggerNextStepIds - stepsNextStepIds - createdStep { - id - name - type - settings - valid - nextStepIds - position { - x - y - } - } + ...WorkflowDiffFragment } } - `; + ${WorkflowDiffFragmentFragmentDoc}`; export type CreateWorkflowVersionStepMutationFn = Apollo.MutationFunction; /** @@ -12505,11 +12499,10 @@ export type DeactivateWorkflowVersionMutationOptions = Apollo.BaseMutationOption export const DeleteWorkflowVersionEdgeDocument = gql` mutation DeleteWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) { deleteWorkflowVersionEdge(input: $input) { - triggerNextStepIds - stepsNextStepIds + ...WorkflowDiffFragment } } - `; + ${WorkflowDiffFragmentFragmentDoc}`; export type DeleteWorkflowVersionEdgeMutationFn = Apollo.MutationFunction; /** @@ -12539,12 +12532,10 @@ export type DeleteWorkflowVersionEdgeMutationOptions = Apollo.BaseMutationOption export const DeleteWorkflowVersionStepDocument = gql` mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) { deleteWorkflowVersionStep(input: $input) { - triggerNextStepIds - stepsNextStepIds - deletedStepIds + ...WorkflowDiffFragment } } - `; + ${WorkflowDiffFragmentFragmentDoc}`; export type DeleteWorkflowVersionStepMutationFn = Apollo.MutationFunction; /** @@ -12574,23 +12565,10 @@ export type DeleteWorkflowVersionStepMutationOptions = Apollo.BaseMutationOption export const DuplicateWorkflowVersionStepDocument = gql` mutation DuplicateWorkflowVersionStep($input: DuplicateWorkflowVersionStepInput!) { duplicateWorkflowVersionStep(input: $input) { - triggerNextStepIds - stepsNextStepIds - createdStep { - id - name - type - settings - valid - nextStepIds - position { - x - y - } - } + ...WorkflowDiffFragment } } - `; + ${WorkflowDiffFragmentFragmentDoc}`; export type DuplicateWorkflowVersionStepMutationFn = Apollo.MutationFunction; /** diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 5921a0ae45e..53195c30ef8 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -837,6 +837,8 @@ export type CreateWorkflowVersionEdgeInput = { }; export type CreateWorkflowVersionStepInput = { + /** Step ID */ + id?: InputMaybe; /** Next step ID */ nextStepId?: InputMaybe; /** Parent step connection options */ @@ -3861,10 +3863,8 @@ export type WorkflowVersion = { export type WorkflowVersionStepChanges = { __typename?: 'WorkflowVersionStepChanges'; - createdStep?: Maybe; - deletedStepIds?: Maybe>; - stepsNextStepIds?: Maybe; - triggerNextStepIds?: Maybe>; + stepsDiff?: Maybe; + triggerDiff?: Maybe; }; export type Workspace = { diff --git a/packages/twenty-front/src/modules/workflow/graphql/fragments/workflowDiffFragment.ts b/packages/twenty-front/src/modules/workflow/graphql/fragments/workflowDiffFragment.ts new file mode 100644 index 00000000000..16375cb5f76 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/graphql/fragments/workflowDiffFragment.ts @@ -0,0 +1,8 @@ +import { gql } from '@apollo/client'; + +export const WORKFLOW_DIFF_FRAGMENT = gql` + fragment WorkflowDiffFragment on WorkflowVersionStepChanges { + triggerDiff + stepsDiff + } +`; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionEdge.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionEdge.ts index e63bd3d3dbc..df61366ee31 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionEdge.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionEdge.ts @@ -1,10 +1,12 @@ +import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment'; import { gql } from '@apollo/client'; export const CREATE_WORKFLOW_VERSION_EDGE = gql` mutation CreateWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) { createWorkflowVersionEdge(input: $input) { - triggerNextStepIds - stepsNextStepIds + ...WorkflowDiffFragment } } + + ${WORKFLOW_DIFF_FRAGMENT} `; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionStep.ts index 8e1922ea36e..37e4081138f 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionStep.ts @@ -1,22 +1,12 @@ +import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment'; import { gql } from '@apollo/client'; export const CREATE_WORKFLOW_VERSION_STEP = gql` mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) { createWorkflowVersionStep(input: $input) { - triggerNextStepIds - stepsNextStepIds - createdStep { - id - name - type - settings - valid - nextStepIds - position { - x - y - } - } + ...WorkflowDiffFragment } } + + ${WORKFLOW_DIFF_FRAGMENT} `; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionEdge.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionEdge.ts index 1cd8c80ed3d..6d63cd7f5bd 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionEdge.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionEdge.ts @@ -1,10 +1,12 @@ +import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment'; import { gql } from '@apollo/client'; export const DELETE_WORKFLOW_VERSION_EDGE = gql` mutation DeleteWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) { deleteWorkflowVersionEdge(input: $input) { - triggerNextStepIds - stepsNextStepIds + ...WorkflowDiffFragment } } + + ${WORKFLOW_DIFF_FRAGMENT} `; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionStep.ts index 7ea7f083ee3..a9630b69b76 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionStep.ts @@ -1,11 +1,12 @@ +import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment'; import { gql } from '@apollo/client'; export const DELETE_WORKFLOW_VERSION_STEP = gql` mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) { deleteWorkflowVersionStep(input: $input) { - triggerNextStepIds - stepsNextStepIds - deletedStepIds + ...WorkflowDiffFragment } } + + ${WORKFLOW_DIFF_FRAGMENT} `; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflowVersionStep.ts index e400f945eeb..f319d174304 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflowVersionStep.ts @@ -1,3 +1,4 @@ +import { WORKFLOW_DIFF_FRAGMENT } from '@/workflow/graphql/fragments/workflowDiffFragment'; import { gql } from '@apollo/client'; export const DUPLICATE_WORKFLOW_VERSION_STEP = gql` @@ -5,20 +6,9 @@ export const DUPLICATE_WORKFLOW_VERSION_STEP = gql` $input: DuplicateWorkflowVersionStepInput! ) { duplicateWorkflowVersionStep(input: $input) { - triggerNextStepIds - stepsNextStepIds - createdStep { - id - name - type - settings - valid - nextStepIds - position { - x - y - } - } + ...WorkflowDiffFragment } } + + ${WORKFLOW_DIFF_FRAGMENT} `; diff --git a/packages/twenty-front/src/modules/workflow/utils/getStepDefinitionOrThrow.ts b/packages/twenty-front/src/modules/workflow/utils/getStepDefinitionOrThrow.ts index e1a3c8dc5f0..a8467695ebf 100644 --- a/packages/twenty-front/src/modules/workflow/utils/getStepDefinitionOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/utils/getStepDefinitionOrThrow.ts @@ -37,7 +37,7 @@ export const getStepDefinitionOrThrow = ({ const selectedNodePosition = findStepPosition({ steps, - stepId: stepId, + stepId, }); if (!isDefined(selectedNodePosition)) { return undefined; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx index 21c8533bbd6..6e9f756db2e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx @@ -6,7 +6,15 @@ import { useCreateStep } from '../useCreateStep'; const mockGetUpdatableWorkflowVersion = jest.fn(); const mockCreateWorkflowVersionStep = jest.fn().mockResolvedValue({ data: { - createWorkflowVersionStep: { createdStep: { id: '1', type: 'CODE' } }, + createWorkflowVersionStep: { + stepsDiff: [ + { + type: 'CREATE', + path: ['steps', 0], + value: { id: 'step-id', type: 'CODE' }, + }, + ], + }, }, }); @@ -25,6 +33,8 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({ }), })); +jest.mock('uuid', () => ({ v4: () => 'step-id' })); + const wrapper = ({ children }: { children: React.ReactNode }) => { const workflowVisualizerComponentInstanceId = 'workflow-visualizer-instance-id'; @@ -65,6 +75,7 @@ describe('useCreateStep', () => { expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled(); expect(mockCreateWorkflowVersionStep).toHaveBeenCalledWith({ + id: 'step-id', workflowVersionId: mockWorkflowVersionId, stepType: 'CODE', parentStepId: 'parent-step-id', diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDeleteStep.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDeleteStep.test.ts index 2d55ea312b3..089f50eed9c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDeleteStep.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDeleteStep.test.ts @@ -41,11 +41,18 @@ describe('useDeleteStep', () => { it('should delete step and clean up dependencies', async () => { const mockWorkflowVersionId = 'version-123'; const mockStepId = 'step-1'; - const mockDeletedStepIds = ['step-1', 'step-2']; mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId); mockDeleteWorkflowVersionStep.mockResolvedValue({ - deletedStepIds: mockDeletedStepIds, + deletedStepIds: { + stepsDiff: [ + { + type: 'DELETE', + path: ['steps', 0], + value: mockStepId, + }, + ], + }, }); const { result } = renderHook(() => useDeleteStep()); @@ -58,7 +65,7 @@ describe('useDeleteStep', () => { }); expect(mockCloseCommandMenu).toHaveBeenCalled(); expect(mockDeleteStepsOutputSchema).toHaveBeenCalledWith({ - stepIds: mockDeletedStepIds, + stepIds: [mockStepId], workflowVersionId: mockWorkflowVersionId, }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDuplicateStep.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDuplicateStep.test.tsx index 09525cba6dd..5a1ffd83d01 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDuplicateStep.test.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDuplicateStep.test.tsx @@ -6,7 +6,15 @@ import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-di const mockGetUpdatableWorkflowVersion = jest.fn(); const mockDuplicateWorkflowVersionStep = jest.fn().mockResolvedValue({ data: { - duplicateWorkflowVersionStep: { createdStep: { id: '2', type: 'CODE' } }, + duplicateWorkflowVersionStep: { + stepsDiff: [ + { + type: 'CREATE', + path: ['steps', 0], + value: { id: '2', type: 'CODE' }, + }, + ], + }, }, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts index 15c8dca985f..f4ba681ed3f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts @@ -5,8 +5,15 @@ import { type WorkflowStepType } from '@/workflow/types/Workflow'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { type WorkflowStepConnectionOptions } from '@/workflow/workflow-diagram/workflow-iterator/types/WorkflowStepConnectionOptions'; import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep'; +import { + type Difference, + type DifferenceChange, + type DifferenceCreate, +} from 'microdiff'; import { useState } from 'react'; +import { type Nullable } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; export const useCreateStep = () => { const [isLoading, setIsLoading] = useState(false); @@ -42,9 +49,11 @@ export const useCreateStep = () => { try { const workflowVersionId = await getUpdatableWorkflowVersion(); + const id = v4(); const workflowVersionStepChanges = ( await createWorkflowVersionStep({ + id, workflowVersionId, stepType: newStepType, parentStepId, @@ -54,16 +63,26 @@ export const useCreateStep = () => { }) )?.data?.createWorkflowVersionStep; - const createdStep = workflowVersionStepChanges?.createdStep; + const stepsDiff = workflowVersionStepChanges?.stepsDiff as Difference[]; - if (!isDefined(createdStep)) { + const addedStepDiff = stepsDiff?.find( + (diff) => diff.type === 'CREATE' && diff.value.id === id, + ) as Nullable; + + const createdFirstStepDiff = stepsDiff?.find( + (diff) => diff.type === 'CHANGE' && diff.value?.[0]?.id === id, + ) as Nullable; + + if (!isDefined(createdFirstStepDiff) && !isDefined(addedStepDiff)) { throw new Error("Couldn't create step"); } - setWorkflowSelectedNode(createdStep.id); - setWorkflowLastCreatedStepId(createdStep.id); + setWorkflowSelectedNode(id); + setWorkflowLastCreatedStepId(id); - return createdStep; + return isDefined(createdFirstStepDiff) + ? createdFirstStepDiff.value[0] + : addedStepDiff?.value; } finally { setIsLoading(false); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts index 9489ccd2a15..babf9ac83b8 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts @@ -14,7 +14,7 @@ export const useDeleteStep = () => { const deleteStep = async (stepId: string) => { const workflowVersionId = await getUpdatableWorkflowVersion(); - const workflowVersionStepChanges = await deleteWorkflowVersionStep({ + await deleteWorkflowVersionStep({ workflowVersionId, stepId, }); @@ -22,7 +22,7 @@ export const useDeleteStep = () => { closeCommandMenu(); deleteStepsOutputSchema({ - stepIds: workflowVersionStepChanges?.deletedStepIds ?? [], + stepIds: [stepId], workflowVersionId, }); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateStep.ts index 85a156c0510..2ec9e3d582c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateStep.ts @@ -3,6 +3,7 @@ import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUp import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { useDuplicateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep'; +import { type Difference } from 'microdiff'; import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -36,16 +37,17 @@ export const useDuplicateStep = () => { }) )?.data?.duplicateWorkflowVersionStep; - const createdStep = workflowVersionStepChanges?.createdStep; + const stepsDiff = workflowVersionStepChanges?.stepsDiff as Difference[]; + const createdStepDiff = stepsDiff?.find((diff) => diff.type === 'CREATE'); - if (!isDefined(createdStep)) { + if (!isDefined(createdStepDiff)) { throw new Error("Couldn't duplicate step"); } - setWorkflowSelectedNode(createdStep.id); - setWorkflowLastCreatedStepId(createdStep.id); + setWorkflowSelectedNode(createdStepDiff.value.id); + setWorkflowLastCreatedStepId(createdStepDiff.value.id); - return createdStep; + return createdStepDiff.value; } finally { setIsLoading(false); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts index 8960ea38829..fdff55a98ba 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts @@ -5,11 +5,8 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { - type WorkflowAction, - type WorkflowVersion, -} from '@/workflow/types/Workflow'; -import { isDefined } from 'twenty-shared/utils'; +import { type WorkflowVersion } from '@/workflow/types/Workflow'; +import { applyDiff, isDefined } from 'twenty-shared/utils'; import { type WorkflowVersionStepChanges } from '~/generated/graphql'; export const useUpdateWorkflowVersionCache = () => { @@ -44,47 +41,14 @@ export const useUpdateWorkflowVersionCache = () => { return; } - const { - triggerNextStepIds, - stepsNextStepIds, - createdStep, - deletedStepIds, - } = workflowVersionStepChanges; + const { triggerDiff, stepsDiff } = workflowVersionStepChanges; const newCachedRecord = { ...cachedRecord, - trigger: isDefined(cachedRecord.trigger) - ? { - ...cachedRecord.trigger, - nextStepIds: triggerNextStepIds, - } - : cachedRecord.trigger, - steps: (cachedRecord.steps || []).map((step: WorkflowAction) => ({ - ...step, - nextStepIds: stepsNextStepIds[step.id] ?? step.nextStepIds, - })), - } satisfies WorkflowVersion; - - if (isDefined(createdStep)) { - const formattedCreatedStep = { - ...(createdStep as WorkflowAction), - nextStepIds: createdStep.nextStepIds || [], - }; - - newCachedRecord.steps.push(formattedCreatedStep); - } - - if (isDefined(deletedStepIds) && deletedStepIds.length > 0) { - newCachedRecord.steps = newCachedRecord.steps.filter( - (step: WorkflowAction) => !deletedStepIds.includes(step.id), - ); - - const hasDeletedTrigger: boolean = deletedStepIds.includes('trigger'); - - if (hasDeletedTrigger) { - newCachedRecord.trigger = null; - } - } + steps: applyDiff({ steps: cachedRecord.steps }, stepsDiff).steps, + trigger: applyDiff({ trigger: cachedRecord.trigger }, triggerDiff) + .trigger, + } as WorkflowVersion; const recordGqlFields = { steps: true, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto.ts index e181fcd5734..51758dc6302 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto.ts @@ -45,4 +45,10 @@ export class CreateWorkflowVersionStepInput { nullable: true, }) position?: WorkflowStepPositionInput; + + @Field(() => String, { + description: 'Step ID', + nullable: true, + }) + id?: string; } diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-action.dto.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step.dto.ts rename to packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-action.dto.ts diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto.ts index 50841a60d57..782604a86ff 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto.ts @@ -1,20 +1,13 @@ import { Field, ObjectType } from '@nestjs/graphql'; -import graphqlTypeJson from 'graphql-type-json'; - -import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto'; +import GraphQLJSON from 'graphql-type-json'; +import { Difference } from 'microdiff'; @ObjectType('WorkflowVersionStepChanges') export class WorkflowVersionStepChangesDTO { - @Field(() => [String], { nullable: true }) - triggerNextStepIds?: string[]; + @Field(() => GraphQLJSON, { nullable: true }) + triggerDiff?: Difference[]; - @Field(() => graphqlTypeJson, { nullable: true }) - stepsNextStepIds?: Record; - - @Field(() => WorkflowActionDTO, { nullable: true }) - createdStep?: WorkflowActionDTO; - - @Field(() => [String], { nullable: true }) - deletedStepIds?: string[]; + @Field(() => GraphQLJSON, { nullable: true }) + stepsDiff?: Difference[]; } diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts index 1069eb68e13..07387ff9563 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts @@ -11,7 +11,7 @@ import { DuplicateWorkflowVersionStepInput } from 'src/engine/core-modules/workf import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto'; import { UpdateWorkflowRunStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-run-step-input.dto'; import { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto'; -import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto'; +import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto'; import { WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; import { WorkflowVersionStepGraphqlApiExceptionFilter } from 'src/engine/core-modules/workflow/filters/workflow-version-step-graphql-api-exception.filter'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; 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 49b1370a968..4b226b1176a 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,24 +1,191 @@ import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util'; -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'; +import { + type WorkflowAction, + WorkflowActionType, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { + type WorkflowTrigger, + WorkflowTriggerType, +} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; describe('computeWorkflowVersionStepChanges', () => { - it('should compute next step ids', () => { - const input = { - trigger: { nextStepIds: ['1', '2'] } as WorkflowTrigger, - steps: [ - { id: '1', nextStepIds: ['3'] }, - { id: '2', nextStepIds: ['3'] }, - ] as WorkflowAction[], - deletedStepIds: ['5'], + const mockTrigger: WorkflowTrigger = { + name: 'Test Manual Trigger', + type: WorkflowTriggerType.MANUAL, + settings: { + input: {}, + outputSchema: {}, + }, + nextStepIds: ['step-1'], + }; + + const mockSteps: WorkflowAction[] = [ + { + id: 'step-1', + name: 'Form Step', + type: WorkflowActionType.FORM, + settings: { + input: [], + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + outputSchema: {}, + }, + valid: true, + nextStepIds: ['step-2'], + }, + { + id: 'step-2', + name: 'Email Step', + type: WorkflowActionType.SEND_EMAIL, + settings: { + input: { + connectedAccountId: '', + email: '', + subject: '', + body: '', + }, + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + outputSchema: {}, + }, + valid: true, + nextStepIds: [], + }, + ]; + + it('should compute trigger diff when trigger is updated', () => { + const existingTrigger = mockTrigger; + const updatedTrigger = { + ...mockTrigger, + nextStepIds: ['step-1', 'step-3'], }; - const expectedResult = { - triggerNextStepIds: ['1', '2'], - stepsNextStepIds: { '1': ['3'], '2': ['3'] }, - deletedStepIds: ['5'], - }; + const result = computeWorkflowVersionStepChanges({ + existingTrigger, + existingSteps: mockSteps, + updatedTrigger, + updatedSteps: mockSteps, + }); - expect(computeWorkflowVersionStepChanges(input)).toEqual(expectedResult); + expect(result.triggerDiff).toMatchObject([ + { path: ['trigger', 'nextStepIds', 1], type: 'CREATE', value: 'step-3' }, + ]); + expect(result.stepsDiff).toMatchObject([]); + expect(result.stepsDiff.length).toBe(0); // No steps changed + + // Verify the trigger diff contains the nextStepIds change + const nextStepIdsDiff = result.triggerDiff.find((diff) => + diff.path.includes('nextStepIds'), + ); + + expect(nextStepIdsDiff).toMatchObject({ + type: 'CREATE', + path: ['trigger', 'nextStepIds', 1], + value: 'step-3', + }); + }); + + it('should compute steps diff when steps are updated', () => { + const existingSteps = mockSteps; + const updatedSteps = [ + ...mockSteps, + { + id: 'step-3', + name: 'Code Step', + type: WorkflowActionType.CODE, + settings: { + input: { + serverlessFunctionId: '', + serverlessFunctionVersion: '', + serverlessFunctionInput: {}, + }, + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + outputSchema: {}, + }, + valid: true, + nextStepIds: [], + } as WorkflowAction, + ]; + + const result = computeWorkflowVersionStepChanges({ + existingTrigger: mockTrigger, + existingSteps, + updatedTrigger: mockTrigger, + updatedSteps, + }); + + expect(result.stepsDiff).toBeDefined(); + expect(result.stepsDiff.length).toBeGreaterThan(0); + expect(result.triggerDiff).toBeDefined(); + expect(result.triggerDiff.length).toBe(0); // No trigger changed + + // Verify the steps diff contains the new step + const createDiff = result.stepsDiff.find((diff) => diff.type === 'CREATE'); + + expect(createDiff).toBeDefined(); + }); + + it('should return empty diffs when no changes are made', () => { + const result = computeWorkflowVersionStepChanges({ + existingTrigger: mockTrigger, + existingSteps: mockSteps, + updatedTrigger: mockTrigger, + updatedSteps: mockSteps, + }); + + expect(result.triggerDiff).toBeDefined(); + expect(result.triggerDiff.length).toBe(0); + expect(result.stepsDiff).toBeDefined(); + expect(result.stepsDiff.length).toBe(0); + }); + + it('should handle null existing trigger and steps', () => { + const updatedTrigger = mockTrigger; + const updatedSteps = mockSteps; + + const result = computeWorkflowVersionStepChanges({ + existingTrigger: null, + existingSteps: null, + updatedTrigger, + updatedSteps, + }); + + expect(result.triggerDiff).toBeDefined(); + expect(result.triggerDiff.length).toBeGreaterThan(0); + expect(result.stepsDiff).toBeDefined(); + expect(result.stepsDiff.length).toBeGreaterThan(0); + + // Verify change diffs are present + const triggerChangeDiff = result.triggerDiff.find( + (diff) => diff.type === 'CHANGE', + ); + + expect(triggerChangeDiff).toBeDefined(); + + const stepsChangeDiff = result.stepsDiff.find( + (diff) => diff.type === 'CHANGE', + ); + + expect(stepsChangeDiff).toBeDefined(); + }); + + it('should return empty diffs when updated values are undefined', () => { + const result = computeWorkflowVersionStepChanges({ + existingTrigger: mockTrigger, + existingSteps: mockSteps, + // updatedTrigger and updatedSteps are undefined + }); + + expect(result.triggerDiff).toBeDefined(); + expect(result.triggerDiff.length).toBe(0); + expect(result.stepsDiff).toBeDefined(); + expect(result.stepsDiff.length).toBe(0); }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util.ts index 54572a03468..d5ef145e761 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util.ts @@ -1,24 +1,28 @@ -import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; +import { isDefined } from 'class-validator'; +import diff, { type Difference } from 'microdiff'; + 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 computeWorkflowVersionStepChanges = ({ - trigger, - steps, - createdStep, - deletedStepIds, + existingTrigger, + existingSteps, + updatedTrigger, + updatedSteps, }: { - trigger: WorkflowTrigger | null; - steps: WorkflowAction[] | null; - createdStep?: WorkflowAction; - deletedStepIds?: string[]; -}): WorkflowVersionStepChangesDTO => { - return { - triggerNextStepIds: trigger?.nextStepIds, - stepsNextStepIds: Object.fromEntries( - (steps || []).map((step) => [step.id, step.nextStepIds]), - ), - createdStep, - deletedStepIds, - }; + existingTrigger: WorkflowTrigger | null; + existingSteps: WorkflowAction[] | null; + updatedTrigger?: WorkflowTrigger | null; + updatedSteps?: WorkflowAction[] | null; +}) => { + const triggerDiff: Difference[] = + updatedTrigger !== undefined + ? diff({ trigger: existingTrigger }, { trigger: updatedTrigger }) + : []; + + const stepsDiff: Difference[] = isDefined(updatedSteps) + ? diff({ steps: existingSteps }, { steps: updatedSteps }) + : []; + + return { triggerDiff, stepsDiff }; }; 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 eaf4d90d8e5..26982a07e25 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 @@ -181,12 +181,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { }); expect(result).toEqual({ - triggerNextStepIds: ['step-1', 'step-3'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + triggerDiff: [ + { + path: ['trigger', 'nextStepIds', 1], + type: 'CREATE', + value: 'step-3', + }, + ], + stepsDiff: [], }); }); @@ -203,12 +205,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { ).not.toHaveBeenCalled(); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + triggerDiff: [], + stepsDiff: [], }); }); }); @@ -272,13 +270,21 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { }); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - 'iterator-step': ['step-2'], - }, + stepsDiff: [ + { + path: [ + 'steps', + 3, + 'settings', + 'input', + 'initialLoopStepIds', + 1, + ], + type: 'CREATE', + value: 'step-3', + }, + ], + triggerDiff: [], }); }); @@ -301,13 +307,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { ).not.toHaveBeenCalled(); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - 'iterator-step': ['step-2'], - }, + stepsDiff: [], + triggerDiff: [], }); }); @@ -357,13 +358,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { }); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - 'iterator-step': ['step-2', 'step-3'], - }, + stepsDiff: [ + { + path: ['steps', 3, 'nextStepIds', 1], + type: 'CREATE', + value: 'step-3', + }, + ], + triggerDiff: [], }); }); }); @@ -392,12 +394,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { }); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': ['step-3'], - 'step-3': [], - }, + stepsDiff: [ + { + path: ['steps', 1, 'nextStepIds', 0], + type: 'CREATE', + value: 'step-3', + }, + ], + triggerDiff: [], }); }); @@ -414,12 +418,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { ).not.toHaveBeenCalled(); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + stepsDiff: [], + triggerDiff: [], }); }); @@ -480,12 +480,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { }); expect(result).toEqual({ - triggerNextStepIds: [], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + triggerDiff: [ + { + oldValue: 'step-1', + path: ['trigger', 'nextStepIds', 0], + type: 'REMOVE', + }, + ], + stepsDiff: [], }); }); @@ -509,12 +511,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { ).not.toHaveBeenCalled(); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + stepsDiff: [], + triggerDiff: [], }); }); }); @@ -544,12 +542,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { }); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': [], - 'step-2': [], - 'step-3': [], - }, + stepsDiff: [ + { + oldValue: 'step-2', + path: ['steps', 0, 'nextStepIds', 0], + type: 'REMOVE', + }, + ], + triggerDiff: [], }); }); @@ -566,12 +566,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { ).not.toHaveBeenCalledWith(); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + stepsDiff: [], + triggerDiff: [], }); }); @@ -670,13 +666,34 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { }); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'iterator-step': ['step-1'], - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + stepsDiff: [ + { + oldValue: 'step-2', + path: [ + 'steps', + 0, + 'settings', + 'input', + 'initialLoopStepIds', + 0, + ], + type: 'CHANGE', + value: 'step-3', + }, + { + oldValue: 'step-3', + path: [ + 'steps', + 0, + 'settings', + 'input', + 'initialLoopStepIds', + 1, + ], + type: 'REMOVE', + }, + ], + triggerDiff: [], }); }); @@ -699,13 +716,8 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { ).not.toHaveBeenCalled(); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'iterator-step': ['step-1'], - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + stepsDiff: [], + triggerDiff: [], }); }); @@ -735,13 +747,14 @@ describe('WorkflowVersionEdgeWorkspaceService', () => { }); expect(result).toEqual({ - triggerNextStepIds: ['step-1'], - stepsNextStepIds: { - 'iterator-step': [], - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, + stepsDiff: [ + { + oldValue: 'step-1', + path: ['steps', 0, 'nextStepIds', 0], + type: 'REMOVE', + }, + ], + triggerDiff: [], }); }); }); 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 5347101a46f..cbffeac99e3 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 @@ -176,8 +176,8 @@ export class WorkflowVersionEdgeWorkspaceService { if (trigger.nextStepIds?.includes(target)) { return computeWorkflowVersionStepChanges({ - trigger, - steps, + existingTrigger: trigger, + existingSteps: steps, }); } @@ -191,8 +191,9 @@ export class WorkflowVersionEdgeWorkspaceService { }); return computeWorkflowVersionStepChanges({ - trigger: updatedTrigger, - steps, + existingTrigger: trigger, + existingSteps: steps, + updatedTrigger, }); } @@ -227,8 +228,8 @@ export class WorkflowVersionEdgeWorkspaceService { !isDefined(sourceConnectionOptions) ) { return computeWorkflowVersionStepChanges({ - trigger, - steps, + existingTrigger: trigger, + existingSteps: steps, }); } @@ -257,8 +258,9 @@ export class WorkflowVersionEdgeWorkspaceService { } return computeWorkflowVersionStepChanges({ - trigger, - steps: updatedSteps, + existingTrigger: trigger, + existingSteps: steps, + updatedSteps, }); } @@ -375,8 +377,8 @@ export class WorkflowVersionEdgeWorkspaceService { if (!trigger.nextStepIds?.includes(target)) { return computeWorkflowVersionStepChanges({ - trigger, - steps, + existingTrigger: trigger, + existingSteps: steps, }); } @@ -392,8 +394,9 @@ export class WorkflowVersionEdgeWorkspaceService { }); return computeWorkflowVersionStepChanges({ - trigger: updatedTrigger, - steps, + existingTrigger: trigger, + existingSteps: steps, + updatedTrigger, }); } @@ -428,8 +431,8 @@ export class WorkflowVersionEdgeWorkspaceService { !isDefined(sourceConnectionOptions) ) { return computeWorkflowVersionStepChanges({ - trigger, - steps, + existingTrigger: trigger, + existingSteps: steps, }); } @@ -453,8 +456,8 @@ export class WorkflowVersionEdgeWorkspaceService { if (!shouldPersist) { return computeWorkflowVersionStepChanges({ - trigger, - steps, + existingTrigger: trigger, + existingSteps: steps, }); } @@ -471,8 +474,9 @@ export class WorkflowVersionEdgeWorkspaceService { }); return computeWorkflowVersionStepChanges({ - trigger, - steps: updatedSteps, + existingTrigger: trigger, + existingSteps: steps, + updatedSteps, }); } 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 399ce934de4..0de8830eaf9 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 @@ -191,8 +191,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { workflowVersionId: 'workflow-version-id', }); - expect(result.type).toBe(WorkflowActionType.CODE); - const codeResult = result as unknown as { + expect(result.builtStep.type).toBe(WorkflowActionType.CODE); + const codeResult = result.builtStep as unknown as { settings: { input: { serverlessFunctionId: string; @@ -214,8 +214,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { workflowVersionId: 'workflow-version-id', }); - expect(result.type).toBe(WorkflowActionType.FORM); - expect(result.settings.input).toEqual([]); + expect(result.builtStep.type).toBe(WorkflowActionType.FORM); + expect(result.builtStep.settings.input).toEqual([]); }); }); 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 5a35ef28e5b..ae95bf7f989 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 @@ -15,6 +15,13 @@ import { } 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( + 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util', + () => ({ + computeWorkflowVersionStepChanges: jest.fn(), + }), +); + type MockWorkspaceRepository = Partial< WorkspaceRepository > & { @@ -78,8 +85,16 @@ describe('WorkflowVersionStepWorkspaceService', () => { let twentyORMGlobalManager: jest.Mocked; let service: WorkflowVersionStepWorkspaceService; let mockWorkflowVersionWorkspaceRepository: MockWorkspaceRepository; + let mockComputeWorkflowVersionStepChanges: jest.Mock; beforeEach(async () => { + const { + computeWorkflowVersionStepChanges, + } = require('src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util'); + + mockComputeWorkflowVersionStepChanges = + computeWorkflowVersionStepChanges as jest.Mock; + mockWorkflowVersionWorkspaceRepository = { findOne: jest.fn(), update: jest.fn(), @@ -116,10 +131,13 @@ describe('WorkflowVersionStepWorkspaceService', () => { runStepCreationSideEffectsAndBuildStep: jest .fn() .mockImplementation(({ type }) => ({ - id: 'new-step-id', - type, - settings: {}, - nextStepIds: [], + builtStep: { + id: 'new-step-id', + type, + settings: {}, + nextStepIds: [], + }, + additionalCreatedSteps: [], })), runWorkflowVersionStepDeletionSideEffects: jest.fn(), }, @@ -140,6 +158,25 @@ describe('WorkflowVersionStepWorkspaceService', () => { describe('createWorkflowVersionStep', () => { it('should create a step linked to trigger', async () => { + const mockChanges = { + triggerDiff: [ + { + type: 'CHANGE', + path: ['nextStepIds'], + value: ['step-1', 'new-step-id'], + }, + ], + stepsDiff: [ + { + type: 'CREATE', + path: [], + value: { id: 'new-step-id', type: 'FORM' }, + }, + ], + }; + + mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges); + const result = await service.createWorkflowVersionStep({ input: { stepType: WorkflowActionType.FORM, @@ -151,20 +188,33 @@ describe('WorkflowVersionStepWorkspaceService', () => { }); expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled(); - expect(result.createdStep).toBeDefined(); - - const createdStepId = result.createdStep?.id; - - expect(result.triggerNextStepIds).toEqual(['step-1', createdStepId]); - expect(result.stepsNextStepIds).toEqual({ - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }); + expect(result).toEqual(mockChanges); + expect(result.triggerDiff).toBeDefined(); + expect(result.stepsDiff).toBeDefined(); }); it('should create a step between a trigger and a step', async () => { + const mockChanges = { + triggerDiff: [ + { type: 'CHANGE', path: ['nextStepIds'], value: ['new-step-id'] }, + ], + stepsDiff: [ + { + type: 'CREATE', + path: [], + value: { + id: 'new-step-id', + type: 'FORM', + nextStepIds: ['step-1'], + }, + }, + ], + }; + + mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges); + const result = await service.createWorkflowVersionStep({ input: { stepType: WorkflowActionType.FORM, @@ -176,21 +226,32 @@ describe('WorkflowVersionStepWorkspaceService', () => { }); expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled(); - expect(result.createdStep).toBeDefined(); - - const createdStepId = result.createdStep?.id as string; - - expect(result.triggerNextStepIds).toEqual([createdStepId]); - expect(result.stepsNextStepIds).toEqual({ - [createdStepId]: ['step-1'], - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }); + expect(result).toEqual(mockChanges); + expect(result.triggerDiff).toBeDefined(); + expect(result.stepsDiff).toBeDefined(); }); it('should create a step between two steps', async () => { + const mockChanges = { + triggerDiff: [], + stepsDiff: [ + { type: 'CHANGE', path: [0, 'nextStepIds'], value: ['new-step-id'] }, + { + type: 'CREATE', + path: [], + value: { + id: 'new-step-id', + type: 'FORM', + nextStepIds: ['step-2'], + }, + }, + ], + }; + + mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges); + const result = await service.createWorkflowVersionStep({ input: { stepType: WorkflowActionType.FORM, @@ -202,21 +263,27 @@ describe('WorkflowVersionStepWorkspaceService', () => { }); expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled(); - expect(result.createdStep).toBeDefined(); - - const createdStepId = result.createdStep?.id as string; - - expect(result.triggerNextStepIds).toEqual(['step-1']); - expect(result.stepsNextStepIds).toEqual({ - 'step-1': [createdStepId], - [createdStepId]: ['step-2'], - 'step-2': [], - 'step-3': [], - }); + expect(result).toEqual(mockChanges); + expect(result.triggerDiff).toBeDefined(); + expect(result.stepsDiff).toBeDefined(); }); it('should create a step without parent or children', async () => { + const mockChanges = { + triggerDiff: [], + stepsDiff: [ + { + type: 'CREATE', + path: [], + value: { id: 'new-step-id', type: 'FORM' }, + }, + ], + }; + + mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges); + const result = await service.createWorkflowVersionStep({ input: { stepType: WorkflowActionType.FORM, @@ -228,65 +295,59 @@ describe('WorkflowVersionStepWorkspaceService', () => { }); expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled(); - expect(result.createdStep).toBeDefined(); - - expect(result.triggerNextStepIds).toEqual(['step-1']); - expect(result.stepsNextStepIds).toEqual({ - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }); + expect(result).toEqual(mockChanges); + expect(result.triggerDiff).toBeDefined(); + expect(result.stepsDiff).toBeDefined(); }); }); describe('deleteWorkflowVersionStep', () => { it('should delete step linked to trigger', async () => { + const mockChanges = { + triggerDiff: [ + { type: 'CHANGE', path: ['nextStepIds'], value: ['step-2'] }, + ], + stepsDiff: [{ type: 'REMOVE', path: [0] }], + }; + + mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges); + const result = await service.deleteWorkflowVersionStep({ stepIdToDelete: 'step-1', workflowVersionId: mockWorkflowVersionId, workspaceId: mockWorkspaceId, }); - expect( - mockWorkflowVersionWorkspaceRepository.update, - ).toHaveBeenCalledWith(mockWorkflowVersionId, { - trigger: { ...mockTrigger, nextStepIds: ['step-2'] }, - steps: mockSteps.filter((step) => step.id !== 'step-1'), - }); + expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled(); - expect(result).toEqual({ - triggerNextStepIds: ['step-2'], - stepsNextStepIds: { - 'step-2': [], - 'step-3': [], - }, - deletedStepIds: ['step-1'], - }); + expect(result).toEqual(mockChanges); + expect(result.triggerDiff).toBeDefined(); + expect(result.stepsDiff).toBeDefined(); }); it('should delete trigger', async () => { + const mockChanges = { + triggerDiff: [{ type: 'REMOVE', path: [] }], + stepsDiff: [], + }; + + mockComputeWorkflowVersionStepChanges.mockReturnValue(mockChanges); + const result = await service.deleteWorkflowVersionStep({ stepIdToDelete: TRIGGER_STEP_ID, workflowVersionId: mockWorkflowVersionId, workspaceId: mockWorkspaceId, }); - expect( - mockWorkflowVersionWorkspaceRepository.update, - ).toHaveBeenCalledWith(mockWorkflowVersionId, { - trigger: null, - steps: mockSteps, - }); + expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + expect(mockComputeWorkflowVersionStepChanges).toHaveBeenCalled(); - expect(result).toEqual({ - stepsNextStepIds: { - 'step-1': ['step-2'], - 'step-2': [], - 'step-3': [], - }, - deletedStepIds: [TRIGGER_STEP_ID], - }); + expect(result).toEqual(mockChanges); + expect(result.triggerDiff).toBeDefined(); + expect(result.stepsDiff).toBeDefined(); }); }); }); 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 0f0aa4c6c64..673e5870502 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 @@ -101,16 +101,19 @@ export class WorkflowVersionStepOperationsWorkspaceService { workspaceId, workflowVersionId, position, + id, }: { type: WorkflowActionType; workspaceId: string; workflowVersionId: string; position?: WorkflowStepPositionInput; - }): Promise { - const newStepId = v4(); - + id?: string; + }): Promise<{ + builtStep: WorkflowAction; + additionalCreatedSteps?: WorkflowAction[]; + }> { const baseStep = { - id: newStepId, + id: id || v4(), position, valid: false, nextStepIds: [], @@ -135,40 +138,44 @@ export class WorkflowVersionStepOperationsWorkspaceService { } return { - ...baseStep, - name: 'Code - Serverless Function', - type: WorkflowActionType.CODE, - settings: { - ...BASE_STEP_DEFINITION, - outputSchema: { - link: { - isLeaf: true, - icon: 'IconVariable', - tab: 'test', - label: 'Generate Function Output', + builtStep: { + ...baseStep, + name: 'Code - Serverless Function', + type: WorkflowActionType.CODE, + settings: { + ...BASE_STEP_DEFINITION, + outputSchema: { + link: { + isLeaf: true, + icon: 'IconVariable', + tab: 'test', + label: 'Generate Function Output', + }, + _outputSchemaType: 'LINK', + }, + input: { + serverlessFunctionId: newServerlessFunction.id, + serverlessFunctionVersion: 'draft', + serverlessFunctionInput: BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA, }, - _outputSchemaType: 'LINK', - }, - input: { - serverlessFunctionId: newServerlessFunction.id, - serverlessFunctionVersion: 'draft', - serverlessFunctionInput: BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA, }, }, }; } case WorkflowActionType.SEND_EMAIL: { return { - ...baseStep, - name: 'Send Email', - type: WorkflowActionType.SEND_EMAIL, - settings: { - ...BASE_STEP_DEFINITION, - input: { - connectedAccountId: '', - email: '', - subject: '', - body: '', + builtStep: { + ...baseStep, + name: 'Send Email', + type: WorkflowActionType.SEND_EMAIL, + settings: { + ...BASE_STEP_DEFINITION, + input: { + connectedAccountId: '', + email: '', + subject: '', + body: '', + }, }, }, }; @@ -180,14 +187,16 @@ export class WorkflowVersionStepOperationsWorkspaceService { }); return { - ...baseStep, - name: 'Create Record', - type: WorkflowActionType.CREATE_RECORD, - settings: { - ...BASE_STEP_DEFINITION, - input: { - objectName: activeObjectMetadataItem?.nameSingular || '', - objectRecord: {}, + builtStep: { + ...baseStep, + name: 'Create Record', + type: WorkflowActionType.CREATE_RECORD, + settings: { + ...BASE_STEP_DEFINITION, + input: { + objectName: activeObjectMetadataItem?.nameSingular || '', + objectRecord: {}, + }, }, }, }; @@ -199,16 +208,18 @@ export class WorkflowVersionStepOperationsWorkspaceService { }); return { - ...baseStep, - name: 'Update Record', - type: WorkflowActionType.UPDATE_RECORD, - settings: { - ...BASE_STEP_DEFINITION, - input: { - objectName: activeObjectMetadataItem?.nameSingular || '', - objectRecord: {}, - objectRecordId: '', - fieldsToUpdate: [], + builtStep: { + ...baseStep, + name: 'Update Record', + type: WorkflowActionType.UPDATE_RECORD, + settings: { + ...BASE_STEP_DEFINITION, + input: { + objectName: activeObjectMetadataItem?.nameSingular || '', + objectRecord: {}, + objectRecordId: '', + fieldsToUpdate: [], + }, }, }, }; @@ -220,14 +231,16 @@ export class WorkflowVersionStepOperationsWorkspaceService { }); return { - ...baseStep, - name: 'Delete Record', - type: WorkflowActionType.DELETE_RECORD, - settings: { - ...BASE_STEP_DEFINITION, - input: { - objectName: activeObjectMetadataItem?.nameSingular || '', - objectRecordId: '', + builtStep: { + ...baseStep, + name: 'Delete Record', + type: WorkflowActionType.DELETE_RECORD, + settings: { + ...BASE_STEP_DEFINITION, + input: { + objectName: activeObjectMetadataItem?.nameSingular || '', + objectRecordId: '', + }, }, }, }; @@ -239,69 +252,79 @@ export class WorkflowVersionStepOperationsWorkspaceService { }); return { - ...baseStep, - name: 'Search Records', - type: WorkflowActionType.FIND_RECORDS, - settings: { - ...BASE_STEP_DEFINITION, - input: { - objectName: activeObjectMetadataItem?.nameSingular || '', - limit: 1, + builtStep: { + ...baseStep, + name: 'Search Records', + type: WorkflowActionType.FIND_RECORDS, + settings: { + ...BASE_STEP_DEFINITION, + input: { + objectName: activeObjectMetadataItem?.nameSingular || '', + limit: 1, + }, }, }, }; } case WorkflowActionType.FORM: { return { - ...baseStep, - name: 'Form', - type: WorkflowActionType.FORM, - settings: { - ...BASE_STEP_DEFINITION, - input: [], + builtStep: { + ...baseStep, + name: 'Form', + type: WorkflowActionType.FORM, + settings: { + ...BASE_STEP_DEFINITION, + input: [], + }, }, }; } case WorkflowActionType.FILTER: { return { - ...baseStep, - name: 'Filter', - type: WorkflowActionType.FILTER, - settings: { - ...BASE_STEP_DEFINITION, - input: { - stepFilterGroups: [], - stepFilters: [], + builtStep: { + ...baseStep, + name: 'Filter', + type: WorkflowActionType.FILTER, + settings: { + ...BASE_STEP_DEFINITION, + input: { + stepFilterGroups: [], + stepFilters: [], + }, }, }, }; } case WorkflowActionType.HTTP_REQUEST: { return { - ...baseStep, - name: 'HTTP Request', - type: WorkflowActionType.HTTP_REQUEST, - settings: { - ...BASE_STEP_DEFINITION, - input: { - url: '', - method: 'GET', - headers: {}, - body: {}, + builtStep: { + ...baseStep, + name: 'HTTP Request', + type: WorkflowActionType.HTTP_REQUEST, + settings: { + ...BASE_STEP_DEFINITION, + input: { + url: '', + method: 'GET', + headers: {}, + body: {}, + }, }, }, }; } case WorkflowActionType.AI_AGENT: { return { - ...baseStep, - name: 'AI Agent', - type: WorkflowActionType.AI_AGENT, - settings: { - ...BASE_STEP_DEFINITION, - input: { - agentId: '', - prompt: '', + builtStep: { + ...baseStep, + name: 'AI Agent', + type: WorkflowActionType.AI_AGENT, + settings: { + ...BASE_STEP_DEFINITION, + input: { + agentId: '', + prompt: '', + }, }, }, }; @@ -315,16 +338,19 @@ export class WorkflowVersionStepOperationsWorkspaceService { }); return { - ...baseStep, - name: 'Iterator', - type: WorkflowActionType.ITERATOR, - settings: { - ...BASE_STEP_DEFINITION, - input: { - items: [], - initialLoopStepIds: [emptyNodeStep.id], + builtStep: { + ...baseStep, + name: 'Iterator', + type: WorkflowActionType.ITERATOR, + settings: { + ...BASE_STEP_DEFINITION, + input: { + items: [], + initialLoopStepIds: [emptyNodeStep.id], + }, }, }, + additionalCreatedSteps: [emptyNodeStep], }; } default: diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts index 525693d4d1e..46b3ae4d7a7 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts @@ -4,6 +4,7 @@ import { isDefined } from 'twenty-shared/utils'; import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; import { type CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; +import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto'; import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { @@ -43,24 +44,9 @@ export class WorkflowVersionStepWorkspaceService { nextStepId, position, parentStepConnectionOptions, + id, } = input; - const newStep = - await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep( - { - type: stepType, - workspaceId, - position, - workflowVersionId, - }, - ); - - const enrichedNewStep = - await this.workflowSchemaWorkspaceService.enrichOutputSchema({ - step: newStep, - workspaceId, - }); - const workflowVersion = await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ workflowVersionId, @@ -69,12 +55,29 @@ export class WorkflowVersionStepWorkspaceService { assertWorkflowVersionIsDraft(workflowVersion); - const existingSteps = workflowVersion.steps || []; + const existingSteps = workflowVersion.steps; const existingTrigger = workflowVersion.trigger; - const { updatedSteps, updatedInsertedStep, updatedTrigger } = insertStep({ - existingSteps, + const { builtStep, additionalCreatedSteps } = + await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep( + { + type: stepType, + workspaceId, + position, + workflowVersionId, + id, + }, + ); + + const enrichedNewStep = + await this.workflowSchemaWorkspaceService.enrichOutputSchema({ + step: builtStep, + workspaceId, + }); + + const { updatedSteps, updatedTrigger } = insertStep({ + existingSteps: existingSteps ?? [], existingTrigger, insertedStep: enrichedNewStep, parentStepId, @@ -82,6 +85,10 @@ export class WorkflowVersionStepWorkspaceService { parentStepConnectionOptions, }); + if (isDefined(additionalCreatedSteps)) { + updatedSteps.push(...additionalCreatedSteps); + } + const workflowVersionRepository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( workspaceId, @@ -95,9 +102,10 @@ export class WorkflowVersionStepWorkspaceService { }); return computeWorkflowVersionStepChanges({ - createdStep: updatedInsertedStep, - trigger: updatedTrigger, - steps: updatedSteps, + existingTrigger, + existingSteps, + updatedTrigger, + updatedSteps, }); } @@ -109,7 +117,7 @@ export class WorkflowVersionStepWorkspaceService { workspaceId: string; workflowVersionId: string; step: WorkflowAction; - }): Promise { + }): Promise { const workflowVersion = await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ workflowVersionId, @@ -252,9 +260,10 @@ export class WorkflowVersionStepWorkspaceService { ); return computeWorkflowVersionStepChanges({ - steps: updatedSteps, - trigger: updatedTrigger, - deletedStepIds: removedStepIds, + existingTrigger, + existingSteps: workflowVersion.steps, + updatedTrigger, + updatedSteps, }); } @@ -294,7 +303,7 @@ export class WorkflowVersionStepWorkspaceService { }, ); - const { updatedSteps, updatedInsertedStep, updatedTrigger } = insertStep({ + const { updatedSteps, updatedTrigger } = insertStep({ existingSteps: workflowVersion.steps ?? [], existingTrigger: workflowVersion.trigger, insertedStep: duplicatedStep, @@ -313,9 +322,10 @@ export class WorkflowVersionStepWorkspaceService { }); return computeWorkflowVersionStepChanges({ - createdStep: updatedInsertedStep, - trigger: updatedTrigger, - steps: updatedSteps, + existingTrigger: workflowVersion.trigger, + existingSteps: workflowVersion.steps, + updatedTrigger, + updatedSteps, }); } @@ -350,7 +360,7 @@ export class WorkflowVersionStepWorkspaceService { }, ); - const defaultStep = + const { builtStep } = await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep( { type: newStep.type, @@ -362,7 +372,7 @@ export class WorkflowVersionStepWorkspaceService { return this.workflowSchemaWorkspaceService.enrichOutputSchema({ step: { - ...defaultStep, + ...builtStep, id: existingStep.id, nextStepIds: existingStep.nextStepIds, position: existingStep.position, diff --git a/packages/twenty-shared/src/utils/__tests__/applyDiff.test.ts b/packages/twenty-shared/src/utils/__tests__/applyDiff.test.ts new file mode 100644 index 00000000000..87909362159 --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/applyDiff.test.ts @@ -0,0 +1,543 @@ +import { type Difference } from 'microdiff'; + +import { applyDiff } from '@/utils/applyDiff'; + +describe('applyDiff', () => { + describe('input validation', () => { + it('should throw error for non-array diffs', () => { + const obj = { test: 'value' }; + + expect(() => applyDiff(obj, null as any)).toThrow( + 'Diffs must be an array' + ); + expect(() => applyDiff(obj, 'invalid' as any)).toThrow( + 'Diffs must be an array' + ); + }); + + it('should handle empty diffs array', () => { + const obj = { test: 'value' }; + const result = applyDiff(obj, []); + + expect(result).toEqual({ test: 'value' }); + expect(result).not.toBe(obj); // Should return a copy + }); + + it('should skip invalid diffs', () => { + const obj = { test: 'value' }; + const diffs: Difference[] = [ + null as any, + { type: 'CREATE', path: [], value: 'test' } as any, + { type: 'CREATE', path: ['test'], value: 'updated' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ test: 'updated' }); + }); + }); + + describe('CREATE operations', () => { + it('should create new properties in objects', () => { + const obj = { existing: 'value' }; + const diffs: Difference[] = [ + { type: 'CREATE', path: ['newProp'], value: 'newValue' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + existing: 'value', + newProp: 'newValue', + }); + }); + + it('should create nested properties', () => { + const obj = { level1: { existing: 'value' } }; + const diffs: Difference[] = [ + { type: 'CREATE', path: ['level1', 'newProp'], value: 'newValue' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + level1: { + existing: 'value', + newProp: 'newValue', + }, + }); + }); + + it('should create array elements', () => { + const obj = ['existing']; + const diffs: Difference[] = [ + { type: 'CREATE', path: [1], value: 'newElement' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual(['existing', 'newElement']); + }); + + it('should create deeply nested structures with existing intermediate objects', () => { + const obj = { level1: { level2: {} } }; + const diffs: Difference[] = [ + { type: 'CREATE', path: ['level1', 'level2', 'prop'], value: 'deep' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + level1: { + level2: { + prop: 'deep', + }, + }, + }); + }); + + it('should build deeply nested structures with multiple CREATE operations', () => { + const obj = {}; + const diffs: Difference[] = [ + { type: 'CREATE', path: ['level1'], value: {} }, + { type: 'CREATE', path: ['level1', 'level2'], value: {} }, + { type: 'CREATE', path: ['level1', 'level2', 'prop'], value: 'deep' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + level1: { + level2: { + prop: 'deep', + }, + }, + }); + }); + }); + + describe('CHANGE operations', () => { + it('should change existing properties', () => { + const obj = { prop: 'oldValue' }; + const diffs: Difference[] = [ + { type: 'CHANGE', path: ['prop'], oldValue: 'oldValue', value: 'newValue' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ prop: 'newValue' }); + }); + + it('should change nested properties', () => { + const obj = { level1: { prop: 'oldValue' } }; + const diffs: Difference[] = [ + { type: 'CHANGE', path: ['level1', 'prop'], oldValue: 'oldValue', value: 'newValue' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + level1: { prop: 'newValue' }, + }); + }); + + it('should change array elements', () => { + const obj = ['old', 'values']; + const diffs: Difference[] = [ + { type: 'CHANGE', path: [0], oldValue: 'old', value: 'new' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual(['new', 'values']); + }); + + it('should change complex nested structures', () => { + const obj = { + users: [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ], + }; + const diffs: Difference[] = [ + { type: 'CHANGE', path: ['users', 0, 'name'], oldValue: 'John', value: 'Johnny' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + users: [ + { id: 1, name: 'Johnny' }, + { id: 2, name: 'Jane' }, + ], + }); + }); + }); + + describe('REMOVE operations', () => { + it('should remove properties from objects', () => { + const obj = { keep: 'value', remove: 'toDelete' }; + const diffs: Difference[] = [ + { type: 'REMOVE', path: ['remove'], oldValue: 'toDelete' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ keep: 'value' }); + }); + + it('should remove nested properties', () => { + const obj = { + level1: { + keep: 'value', + remove: 'toDelete', + }, + }; + const diffs: Difference[] = [ + { type: 'REMOVE', path: ['level1', 'remove'], oldValue: 'toDelete' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + level1: { keep: 'value' }, + }); + }); + + it('should remove single array element', () => { + const obj = ['keep1', 'remove', 'keep2']; + const diffs: Difference[] = [ + { type: 'REMOVE', path: [1], oldValue: 'remove' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual(['keep1', 'keep2']); + }); + + it('should remove multiple array elements in correct order', () => { + const obj = ['a', 'b', 'c', 'd', 'e']; + const diffs: Difference[] = [ + { type: 'REMOVE', path: [1], oldValue: 'b' }, // Remove 'b' + { type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd' + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual(['a', 'c', 'e']); + }); + + it('should handle complex array removal scenarios', () => { + const obj = ['a', 'b', 'c', 'd', 'e', 'f']; + const diffs: Difference[] = [ + { type: 'REMOVE', path: [0], oldValue: 'a' }, // Remove 'a' + { type: 'REMOVE', path: [2], oldValue: 'c' }, // Remove 'c' + { type: 'REMOVE', path: [4], oldValue: 'e' }, // Remove 'e' + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual(['b', 'd', 'f']); + }); + + it('should remove from nested arrays', () => { + const obj = { + items: [ + { id: 1, tags: ['tag1', 'tag2', 'tag3'] }, + { id: 2, tags: ['tag4', 'tag5'] }, + ], + }; + const diffs: Difference[] = [ + { type: 'REMOVE', path: ['items', 0, 'tags', 1], oldValue: 'tag2' }, // Remove 'tag2' + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + items: [ + { id: 1, tags: ['tag1', 'tag3'] }, + { id: 2, tags: ['tag4', 'tag5'] }, + ], + }); + }); + + it('should throw error for non-numeric array index', () => { + const obj = ['a', 'b', 'c']; + const diffs: Difference[] = [ + { type: 'REMOVE', path: ['invalid'], oldValue: 'invalid' }, + ]; + + expect(() => applyDiff(obj, diffs)).toThrow( + 'Expected numeric index for array removal, got string' + ); + }); + }); + + describe('mixed operations', () => { + it('should apply multiple different operations', () => { + const obj = { + keep: 'value', + change: 'oldValue', + remove: 'toDelete', + nested: { + array: ['a', 'b', 'c'], + }, + }; + + const diffs: Difference[] = [ + { type: 'CREATE', path: ['newProp'], value: 'newValue' }, + { type: 'CHANGE', path: ['change'], oldValue: 'oldValue', value: 'newValue' }, + { type: 'REMOVE', path: ['remove'], oldValue: 'toDelete' }, + { type: 'REMOVE', path: ['nested', 'array', 1], oldValue: 'b' }, // Remove 'b' + { type: 'CREATE', path: ['nested', 'newArray'], value: [1, 2, 3] }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + keep: 'value', + change: 'newValue', + newProp: 'newValue', + nested: { + array: ['a', 'c'], + newArray: [1, 2, 3], + }, + }); + }); + + it('should handle operations on the same array', () => { + const obj = ['a', 'b', 'c', 'd']; + const diffs: Difference[] = [ + { type: 'CHANGE', path: [0], oldValue: 'a', value: 'A' }, // Change 'a' to 'A' + { type: 'REMOVE', path: [1], oldValue: 'b' }, // Remove 'b' + { type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd' + { type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd' + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual(['A', 'c']); + }); + }); + + describe('error handling', () => { + it('should throw error for invalid diff type', () => { + const obj = { test: 'value' }; + const diffs: Difference[] = [ + { type: 'INVALID' as any, path: ['test'], value: 'newValue' }, + ]; + + expect(() => applyDiff(obj, diffs)).toThrow( + 'Unsupported diff type: INVALID' + ); + }); + + it('should throw error with path information for invalid operations', () => { + const obj = { test: 'value' }; + const diffs: Difference[] = [ + { type: 'CHANGE', path: ['nonExistent', 'deep', 'path'], oldValue: 'value', value: 'value' }, + ]; + + expect(() => applyDiff(obj, diffs)).toThrow( + 'Failed to apply diff at path nonExistent.deep.path' + ); + }); + }); + + describe('FORBIDDEN_OBJECT_KEYS protection', () => { + it('should throw error when trying to CREATE forbidden property keys', () => { + const obj = { safe: 'value' }; + + const diffs: Difference[] = [ + { type: 'CREATE', path: ['__proto__'], value: 'malicious' }, + ]; + + expect(() => applyDiff(obj, diffs)).toThrow( + "Refusing to set forbidden property key '__proto__' on object (prototype pollution protection)" + ); + }); + + it('should prevent Unicode escape bypasses of forbidden property keys', () => { + const obj = { safe: 'value' }; + + const unicodeBypassAttempts = [ + // __proto__ with Unicode escapes + '__\u0070roto__', // \u0070 = 'p' + '__\u{70}roto__', // ES6 syntax + '__pr\u006fto__', // \u006f = 'o' + '__proto\u005f\u005f', // \u005f = '_' + + // constructor with Unicode escapes + 'construc\u0074or', // \u0074 = 't' + 'constr\u0075ctor', // \u0075 = 'u' + '\u0063onstructor', // \u0063 = 'c' + + // prototype with Unicode escapes + 'proto\u0074ype', // \u0074 = 't' + 'prototy\u0070e', // \u0070 = 'p' + '\u0070rototype', // \u0070 = 'p' + ]; + + unicodeBypassAttempts.forEach((maliciousKey) => { + const diffs: Difference[] = [ + { type: 'CREATE', path: [maliciousKey], value: 'malicious' }, + ]; + + expect(() => applyDiff(obj, diffs)).toThrow( + new RegExp(`Refusing to set forbidden property key.*prototype pollution protection`) + ); + }); + }); + + it('should throw error when trying to CHANGE forbidden property keys', () => { + const obj = { safe: 'value' }; + + const diffs: Difference[] = [ + { type: 'CHANGE', path: ['constructor'], oldValue: 'old', value: 'malicious' }, + ]; + + expect(() => applyDiff(obj, diffs)).toThrow( + "Refusing to set forbidden property key 'constructor' on object (prototype pollution protection)" + ); + }); + + it('should silently skip removal of forbidden property keys', () => { + const obj = { + safe: 'value', + normalProp: 'normal', + }; + + const diffs: Difference[] = [ + // Try to remove forbidden keys (these should be silently skipped) + { type: 'REMOVE', path: ['__proto__'], oldValue: 'anything' }, + { type: 'REMOVE', path: ['constructor'], oldValue: 'anything' }, + { type: 'REMOVE', path: ['prototype'], oldValue: 'anything' }, + // Remove a normal property (this should work) + { type: 'REMOVE', path: ['safe'], oldValue: 'value' }, + ]; + + const result = applyDiff(obj, diffs); + + // Only the safe property should be removed, normalProp should remain + expect(result).toEqual({ + normalProp: 'normal', + }); + + // Verify safe property was actually removed + expect(result).not.toHaveProperty('safe'); + }); + }); + + describe('immutability', () => { + it('should not modify the original object', () => { + const obj = { prop: 'value', nested: { deep: 'value' } }; + const originalObj = JSON.parse(JSON.stringify(obj)); + + const diffs: Difference[] = [ + { type: 'CHANGE', path: ['prop'], oldValue: 'value', value: 'newValue' }, + { type: 'CHANGE', path: ['nested', 'deep'], oldValue: 'value', value: 'newDeepValue' }, + ]; + + applyDiff(obj, diffs); + + expect(obj).toEqual(originalObj); + }); + + it('should not modify the original array', () => { + const obj = ['a', 'b', 'c']; + const originalObj = [...obj]; + + const diffs: Difference[] = [ + { type: 'REMOVE', path: [1], oldValue: 'b' }, + ]; + + applyDiff(obj, diffs); + + expect(obj).toEqual(originalObj); + }); + + it('should handle frozen objects', () => { + const obj = Object.freeze({ prop: 'value' }); + const diffs: Difference[] = [ + { type: 'CHANGE', path: ['prop'], oldValue: 'value', value: 'newValue' }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ prop: 'newValue' }); + expect(obj.prop).toBe('value'); // Original unchanged + }); + }); + + describe('complex scenarios', () => { + it('should handle workflow-like data structures', () => { + const obj = { + trigger: { + type: 'DATABASE_EVENT', + settings: { table: 'users' }, + }, + steps: [ + { + id: '1', + type: 'CODE', + settings: { code: 'console.log("hello")' }, + }, + { + id: '2', + type: 'EMAIL', + settings: { to: 'test@example.com' }, + }, + ], + }; + + const diffs: Difference[] = [ + // Update trigger settings + { type: 'CHANGE', path: ['trigger', 'settings', 'table'], oldValue: 'users', value: 'contacts' }, + // Remove first step + { type: 'REMOVE', path: ['steps', 0], oldValue: '1' }, + // Update remaining step + { type: 'CHANGE', path: ['steps', 1, 'settings', 'to'], oldValue: 'test@example.com', value: 'new@example.com' }, + // Add new step + { type: 'CREATE', path: ['steps', 2], value: { + id: '3', + type: 'WEBHOOK', + settings: { url: 'https://api.example.com/webhook' }, + }}, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + trigger: { + type: 'DATABASE_EVENT', + settings: { table: 'contacts' }, + }, + steps: [ + { + id: '2', + type: 'EMAIL', + settings: { to: 'new@example.com' }, + }, + { + id: '3', + type: 'WEBHOOK', + settings: { url: 'https://api.example.com/webhook' }, + }, + ], + }); + }); + + it('should handle array with multiple removals and additions', () => { + const obj = { + items: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + { id: 4, name: 'Item 4' }, + { id: 5, name: 'Item 5' }, + ], + }; + + const diffs: Difference[] = [ + // Remove items at indices 1 and 3 (Item 2 and Item 4) + { type: 'REMOVE', path: ['items', 1], oldValue: 'Item 2' }, + { type: 'REMOVE', path: ['items', 3], oldValue: 'Item 4' }, + { type: 'REMOVE', path: ['items', 3], oldValue: 'Item 4' }, + // Update remaining item + { type: 'CHANGE', path: ['items', 0, 'name'], oldValue: 'Item 1', value: 'Updated Item 1' }, + // Add new item + { type: 'CREATE', path: ['items', 5], value: { id: 6, name: 'New Item' } }, + ]; + + const result = applyDiff(obj, diffs); + expect(result).toEqual({ + items: [ + { id: 1, name: 'Updated Item 1' }, + { id: 3, name: 'Item 3' }, + { id: 5, name: 'Item 5' }, + { id: 6, name: 'New Item' }, + ], + }); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/applyDiff.ts b/packages/twenty-shared/src/utils/applyDiff.ts new file mode 100644 index 00000000000..61803138bfa --- /dev/null +++ b/packages/twenty-shared/src/utils/applyDiff.ts @@ -0,0 +1,217 @@ +import { isDefined } from '@/utils/validation'; +import { isNumber, isObject, isString } from '@sniptt/guards'; +import { type Difference } from 'microdiff'; + +type ObjectType = Record; +type ArrayType = unknown[]; +type MutableData = ObjectType | ArrayType; + +type ArrayDeletionFn = () => void; + +// Symbol used to mark array elements for deletion +const REMOVE_SYMBOL = Symbol('micropatch-delete'); + +const FORBIDDEN_OBJECT_KEYS = ['__proto__', 'constructor', 'prototype']; + +export const applyDiff = (obj: T, diffs: Difference[]): T => { + if (!isDefined(obj)) { + throw new Error('Cannot apply diff to null or undefined object'); + } + + if (!Array.isArray(diffs)) { + throw new Error('Diffs must be an array'); + } + + // Create a deep copy to avoid modifying read-only/frozen objects + const mutableObj = deepClone(obj) as T; + const arrayDeletionQueue: ArrayDeletionFn[] = []; + + for (const diff of diffs) { + if (!diff || !diff.path || diff.path.length === 0) { + continue; + } + + try { + applyDiffToPath(mutableObj as MutableData, diff, arrayDeletionQueue); + } catch (error) { + throw new Error(`Failed to apply diff at path ${diff.path.join('.')}: ${error}`); + } + } + + arrayDeletionQueue.forEach((deletionFn) => deletionFn()); + + return mutableObj; +}; + +const applyDiffToPath = ( + obj: MutableData, + diff: Difference, + arrayDeletionQueue: ArrayDeletionFn[] +) => { + const { path, type } = diff; + const value = 'value' in diff ? diff.value : undefined; + const pathLength = path.length; + const lastPathElement = path[pathLength - 1]; + + const parentContainer = navigateToParent(obj, path); + + switch (type) { + case 'CREATE': + case 'CHANGE': + setValueAtPath(parentContainer, lastPathElement, value); + break; + + case 'REMOVE': + handleRemoval(obj, path, parentContainer, lastPathElement, arrayDeletionQueue); + break; + + default: + throw new Error(`Unsupported diff type: ${type}`); + } +}; + +const navigateToParent = (obj: MutableData, path: (string | number)[]): MutableData => { + let current = obj; + + for (let i = 0; i < path.length - 1; i++) { + const pathElement = path[i]; + + if (current === null || current === undefined) { + throw new Error(`Cannot traverse path: found null/undefined at element ${i}`); + } + + if (isNumber(pathElement) && !Array.isArray(current)) { + throw new Error(`Expected array at path element ${i}, got ${typeof current}`); + } + + if (isString(pathElement) && Array.isArray(current)) { + throw new Error(`Expected object at path element ${i}, got array`); + } + + if (Array.isArray(current)) { + current = current[pathElement as number] as MutableData; + } else { + current = (current as ObjectType)[pathElement as string] as MutableData; + } + } + + return current; +}; + +const setValueAtPath = ( + container: MutableData, + pathElement: string | number, + value: unknown +): void => { + if (Array.isArray(container)) { + if (!isNumber(pathElement)) { + throw new Error(`Expected numeric index for array, got ${typeof pathElement}`); + } + + try { + container[pathElement] = value; + } catch (error) { + throw new Error(`Cannot set array element at index ${pathElement}: ${error}. Array may be non-extensible.`); + } + } else if (isObject(container)) { + if (FORBIDDEN_OBJECT_KEYS.includes(pathElement as string)) { + throw new Error(`Refusing to set forbidden property key '${pathElement}' on object (prototype pollution protection)`); + } + + try { + container[pathElement] = value; + } catch (error) { + throw new Error(`Cannot set property '${String(pathElement)}': ${error}. Object may be non-extensible.`); + } + } else { + throw new Error(`Expected object or array, got ${typeof container}`); + } +}; + +const handleRemoval = ( + rootObj: MutableData, + fullPath: (string | number)[], + parentContainer: MutableData, + lastPathElement: string | number, + arrayDeletionQueue: ArrayDeletionFn[] +): void => { + if (Array.isArray(parentContainer)) { + handleArrayRemoval(rootObj, fullPath, parentContainer, lastPathElement, arrayDeletionQueue); + } else { + handleObjectRemoval(parentContainer as ObjectType, lastPathElement); + } +}; + +// Handles removal from arrays by marking for deletion and queuing cleanup +const handleArrayRemoval = ( + rootObj: MutableData, + fullPath: (string | number)[], + parentArray: ArrayType, + index: string | number, + arrayDeletionQueue: ArrayDeletionFn[] +): void => { + if (typeof index !== 'number') { + throw new Error(`Expected numeric index for array removal, got ${typeof index}`); + } + + parentArray[index] = REMOVE_SYMBOL; + + arrayDeletionQueue.push(() => { + if (fullPath.length === 1) { + if (Array.isArray(rootObj)) { + filterRemoveSymbols(rootObj); + } + } else { + filterRemoveSymbols(parentArray); + } + }); +}; + +const handleObjectRemoval = ( + parentObject: ObjectType, + key: string | number +): void => { + if (FORBIDDEN_OBJECT_KEYS.includes(key as string)) { + return; + } + + delete parentObject[key]; +}; + +const filterRemoveSymbols = (array: ArrayType): void => { + const indicesToRemove: number[] = []; + + for (let i = 0; i < array.length; i++) { + if (array[i] === REMOVE_SYMBOL) { + indicesToRemove.push(i); + } + } + + for (let i = indicesToRemove.length - 1; i >= 0; i--) { + array.splice(indicesToRemove[i], 1); + } +}; + +const deepClone = (obj: MutableData): MutableData => { + if (obj === null || !isObject(obj)) { + return obj; + } + + if (typeof structuredClone !== 'undefined') { + try { + return structuredClone(obj) as MutableData; + } catch { + return deepCloneJson(obj); + } + } + + return deepCloneJson(obj); +}; + +const deepCloneJson = (obj: MutableData): MutableData => { + try { + return JSON.parse(JSON.stringify(obj)) as MutableData; + } catch { + throw new Error('Failed to clone object'); + } +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 03886b52497..d8651c8a728 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -7,6 +7,7 @@ * |___/ */ +export { applyDiff } from './applyDiff'; export { filterOutByProperty } from './array/filterOutByProperty'; export { findById } from './array/findById'; export { findByProperty } from './array/findByProperty';