From 27f50c4f4e3064f2f59d470b3fe4b117dea83de0 Mon Sep 17 00:00:00 2001 From: Harshit Singh <73997189+harshit078@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:18:25 +0530 Subject: [PATCH] feat: add-create-update-record in workflow (#14654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - this PR focuses on issue https://github.com/twentyhq/core-team-issues/issues/1476 - Added upsert action ## Visual Appearance Screenshot 2025-10-03 at 12 57
58 PM Screenshot 2025-10-03 at 12 57
48 PM --------- Co-authored-by: Félix Malfait Co-authored-by: Thomas Trompette --- .../src/generated-metadata/graphql.ts | 23 +- .../twenty-front/src/generated/graphql.ts | 19 +- .../src/modules/workflow/types/Workflow.ts | 6 +- .../components/WorkflowRunStepNodeDetail.tsx | 13 + .../components/WorkflowStepDetail.tsx | 11 + .../hooks/__tests__/useCreateStep.test.tsx | 1 + .../workflow-steps/hooks/useCreateStep.ts | 4 +- .../hooks/useUpdateWorkflowRunStep.ts | 5 +- .../hooks/useUpdateWorkflowVersionStep.ts | 8 +- .../WorkflowEditActionUpdateRecord.tsx | 261 +++--------------- .../WorkflowEditActionUpsertRecord.tsx | 85 ++++++ .../components/WorkflowUpdateRecordBody.tsx | 232 ++++++++++++++++ ...WorkflowEditActionUpdateRecord.stories.tsx | 12 +- .../constants/RecordActions.ts | 11 +- .../types/update-record-form-data.type.ts | 6 + .../utils/getActionHeaderTypeOrThrow.ts | 1 + .../workflow-actions/utils/getActionIcon.ts | 1 + .../utils/getActionIconColorOrThrow.ts | 1 + .../utils/shouldDisplayFormField.ts | 1 + .../types/guards/isRecordStepOutputSchema.ts | 7 +- .../hooks/useTidyUpWorkflowVersion.ts | 3 +- .../exceptions/record-crud.exception.ts | 1 + .../record-crud/record-crud.module.ts | 3 + .../services/upsert-record.service.ts | 194 +++++++++++++ .../types/upsert-record-params.type.ts | 10 + .../workflow/dtos/workflow-action.dto.ts | 10 +- .../workspace-entity-manager.ts | 34 +-- .../workflow-schema.workspace-service.ts | 1 + ...rsion-step-operations.workspace-service.ts | 22 ++ .../factories/workflow-action.factory.ts | 4 + .../is-workflow-upsert-record-action.guard.ts | 11 + .../record-crud/record-crud-action.module.ts | 5 + .../workflow-record-crud-action-input.type.ts | 7 + ...rkflow-record-crud-action-settings.type.ts | 5 + .../upsert-record.workflow-action.ts | 93 +++++++ .../types/workflow-action-settings.type.ts | 2 + .../types/workflow-action.type.ts | 8 + packages/twenty-shared/src/workflow/index.ts | 2 + .../schemas/upsert-record-action-schema.ts | 10 + .../upsert-record-action-settings-schema.ts | 12 + .../schemas/workflow-action-schema.ts | 2 + 41 files changed, 879 insertions(+), 268 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowUpdateRecordBody.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/upsert-record-params.type.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-upsert-record-action.guard.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action.ts create mode 100644 packages/twenty-shared/src/workflow/schemas/upsert-record-action-schema.ts create mode 100644 packages/twenty-shared/src/workflow/schemas/upsert-record-action-settings-schema.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3f98c114fa4..ef076f26172 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4508,10 +4508,27 @@ export type WorkflowAction = { nextStepIds?: Maybe>; position?: Maybe; settings: Scalars['JSON']; - type: Scalars['String']; + type: WorkflowActionType; valid: Scalars['Boolean']; }; +export enum WorkflowActionType { + AI_AGENT = 'AI_AGENT', + CODE = 'CODE', + CREATE_RECORD = 'CREATE_RECORD', + DELAY = 'DELAY', + DELETE_RECORD = 'DELETE_RECORD', + EMPTY = 'EMPTY', + FILTER = 'FILTER', + FIND_RECORDS = 'FIND_RECORDS', + FORM = 'FORM', + HTTP_REQUEST = 'HTTP_REQUEST', + ITERATOR = 'ITERATOR', + SEND_EMAIL = 'SEND_EMAIL', + UPDATE_RECORD = 'UPDATE_RECORD', + UPSERT_RECORD = 'UPSERT_RECORD' +} + export type WorkflowRun = { __typename?: 'WorkflowRun'; workflowRunId: Scalars['UUID']; @@ -6066,14 +6083,14 @@ export type UpdateWorkflowRunStepMutationVariables = Exact<{ }>; -export type UpdateWorkflowRunStepMutation = { __typename?: 'Mutation', updateWorkflowRunStep: { __typename?: 'WorkflowAction', id: string, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } }; +export type UpdateWorkflowRunStepMutation = { __typename?: 'Mutation', updateWorkflowRunStep: { __typename?: 'WorkflowAction', id: string, name: string, type: WorkflowActionType, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } }; export type UpdateWorkflowVersionStepMutationVariables = Exact<{ input: UpdateWorkflowVersionStepInput; }>; -export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: string, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } }; +export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: string, name: string, type: WorkflowActionType, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } }; export type SubmitFormStepMutationVariables = Exact<{ input: SubmitFormStepInput; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index db53d8b6208..6e0a4a84d20 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -4336,10 +4336,27 @@ export type WorkflowAction = { nextStepIds?: Maybe>; position?: Maybe; settings: Scalars['JSON']; - type: Scalars['String']; + type: WorkflowActionType; valid: Scalars['Boolean']; }; +export enum WorkflowActionType { + AI_AGENT = 'AI_AGENT', + CODE = 'CODE', + CREATE_RECORD = 'CREATE_RECORD', + DELAY = 'DELAY', + DELETE_RECORD = 'DELETE_RECORD', + EMPTY = 'EMPTY', + FILTER = 'FILTER', + FIND_RECORDS = 'FIND_RECORDS', + FORM = 'FORM', + HTTP_REQUEST = 'HTTP_REQUEST', + ITERATOR = 'ITERATOR', + SEND_EMAIL = 'SEND_EMAIL', + UPDATE_RECORD = 'UPDATE_RECORD', + UPSERT_RECORD = 'UPSERT_RECORD' +} + export type WorkflowRun = { __typename?: 'WorkflowRun'; workflowRunId: Scalars['UUID']; diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 4f82c724c60..a0fabeb7195 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -22,6 +22,7 @@ import { type workflowSendEmailActionSchema, type workflowTriggerSchema, type workflowUpdateRecordActionSchema, + type workflowUpsertRecordActionSchema, type workflowWebhookTriggerSchema, type workflowDelayActionSchema, } from 'twenty-shared/workflow'; @@ -40,6 +41,9 @@ export type WorkflowUpdateRecordAction = z.infer< export type WorkflowDeleteRecordAction = z.infer< typeof workflowDeleteRecordActionSchema >; +export type WorkflowUpsertRecordAction = z.infer< + typeof workflowUpsertRecordActionSchema +>; export type WorkflowFindRecordsAction = z.infer< typeof workflowFindRecordsActionSchema >; @@ -61,6 +65,7 @@ export type WorkflowAction = | WorkflowCreateRecordAction | WorkflowUpdateRecordAction | WorkflowDeleteRecordAction + | WorkflowUpsertRecordAction | WorkflowFindRecordsAction | WorkflowFilterAction | WorkflowFormAction @@ -72,7 +77,6 @@ export type WorkflowAction = export type WorkflowActionType = WorkflowAction['type']; export type WorkflowStep = WorkflowAction; -export type WorkflowStepType = WorkflowStep['type']; export type WorkflowDatabaseEventTrigger = z.infer< typeof workflowDatabaseEventTriggerSchema diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx index 23aaec8df7a..6e1bb2322d5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx @@ -11,6 +11,7 @@ import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workfl import { WorkflowEditActionEmpty } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty'; import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail'; import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord'; +import { WorkflowEditActionUpsertRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord'; import { WorkflowEditActionDelay } from '@/workflow/workflow-steps/workflow-actions/delay-actions/components/WorkflowEditActionDelay'; import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter'; import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords'; @@ -172,6 +173,18 @@ export const WorkflowRunStepNodeDetail = ({ ); } + case 'UPSERT_RECORD': { + return ( + + ); + } + case 'FORM': { return ( + ); + } + case 'FORM': { return ( { stepType: 'CODE', parentStepId: 'parent-step-id', nextStepId: undefined, + parentStepConnectionOptions: undefined, position: undefined, }); }); 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 f4ba681ed3f..3b7d144640e 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 @@ -1,7 +1,7 @@ import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow'; import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState'; -import { type WorkflowStepType } from '@/workflow/types/Workflow'; +import { type WorkflowActionType } 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'; @@ -35,7 +35,7 @@ export const useCreateStep = () => { position, connectionOptions, }: { - newStepType: WorkflowStepType; + newStepType: WorkflowActionType; parentStepId: string | undefined; nextStepId: string | undefined; position?: { x: number; y: number }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowRunStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowRunStep.ts index 3af4120a6b1..8046e2d3be2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowRunStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowRunStep.ts @@ -6,14 +6,13 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { UPDATE_WORKFLOW_RUN_STEP } from '@/workflow/graphql/mutations/updateWorkflowRunStep'; -import { type WorkflowRun } from '@/workflow/types/Workflow'; +import { type WorkflowStep, type WorkflowRun } from '@/workflow/types/Workflow'; import { useMutation } from '@apollo/client'; import { isDefined } from 'twenty-shared/utils'; import { type UpdateWorkflowRunStepInput, type UpdateWorkflowRunStepMutation, type UpdateWorkflowRunStepMutationVariables, - type WorkflowAction, } from '~/generated-metadata/graphql'; export const useUpdateWorkflowRunStep = () => { @@ -60,7 +59,7 @@ export const useUpdateWorkflowRunStep = () => { ...cachedRecord.state, flow: { ...cachedRecord.state.flow, - steps: cachedRecord.state.flow.steps.map((step: WorkflowAction) => { + steps: cachedRecord.state.flow.steps.map((step: WorkflowStep) => { if (step.id === updatedStep.id) { return updatedStep; } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts index 798b85871a1..3e15cb063fa 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts @@ -6,14 +6,16 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { UPDATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/updateWorkflowVersionStep'; -import { type WorkflowVersion } from '@/workflow/types/Workflow'; +import { + type WorkflowVersion, + type WorkflowStep, +} from '@/workflow/types/Workflow'; import { useMutation } from '@apollo/client'; import { isDefined } from 'twenty-shared/utils'; import { type UpdateWorkflowVersionStepInput, type UpdateWorkflowVersionStepMutation, type UpdateWorkflowVersionStepMutationVariables, - type WorkflowAction, } from '~/generated-metadata/graphql'; export const useUpdateWorkflowVersionStep = () => { @@ -52,7 +54,7 @@ export const useUpdateWorkflowVersionStep = () => { const newCachedRecord = { ...cachedRecord, - steps: (cachedRecord.steps || []).map((step: WorkflowAction) => { + steps: (cachedRecord.steps || []).map((step: WorkflowStep) => { if (step.id === updatedStep.id) { return updatedStep; } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx index 4d1184f8b37..72368471dde 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx @@ -1,28 +1,11 @@ -import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; -import { Select } from '@/ui/input/components/Select'; -import { type WorkflowUpdateRecordAction } from '@/workflow/types/Workflow'; -import { useEffect, useState } from 'react'; - import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader'; -import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition'; -import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput'; -import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker'; -import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; -import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; -import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect'; +import { type WorkflowUpdateRecordAction } from '@/workflow/types/Workflow'; import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; -import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; +import { WorkflowUpdateRecordBody } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowUpdateRecordBody'; + import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; -import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField'; -import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; -import { useTheme } from '@emotion/react'; -import { isDefined } from 'twenty-shared/utils'; -import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; -import { HorizontalSeparator, useIcons } from 'twenty-ui/display'; -import { type SelectOption } from 'twenty-ui/input'; -import { type JsonValue } from 'type-fest'; -import { useDebouncedCallback } from 'use-debounce'; -import { RelationType } from '~/generated-metadata/graphql'; +import { type UpdateRecordFormData } from '@/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type'; +import { useIcons } from 'twenty-ui/display'; type WorkflowEditActionUpdateRecordProps = { action: WorkflowUpdateRecordAction; @@ -36,127 +19,39 @@ type WorkflowEditActionUpdateRecordProps = { }; }; -type UpdateRecordFormData = { - objectNameSingular: string; - objectRecordId: string; - fieldsToUpdate: string[]; - [field: string]: unknown; -}; - export const WorkflowEditActionUpdateRecord = ({ action, actionOptions, }: WorkflowEditActionUpdateRecordProps) => { - const theme = useTheme(); - - const { getIcon } = useIcons(); - - const { activeNonSystemObjectMetadataItems } = - useFilteredObjectMetadataItems(); - - const availableMetadata: Array> = - activeNonSystemObjectMetadataItems - .filter((objectMetadataItem) => - canObjectBeManagedByWorkflow({ - nameSingular: objectMetadataItem.nameSingular, - isSystem: objectMetadataItem.isSystem, - }), - ) - .map((item) => ({ - Icon: getIcon(item.icon), - label: item.labelPlural, - value: item.nameSingular, - })); - - const [formData, setFormData] = useState({ - objectNameSingular: action.settings.input.objectName, - objectRecordId: action.settings.input.objectRecordId, - fieldsToUpdate: action.settings.input.fieldsToUpdate ?? [], - ...action.settings.input.objectRecord, - }); - - const isFormDisabled = actionOptions.readonly; - - const handleFieldChange = ( - fieldName: keyof UpdateRecordFormData, - updatedValue: JsonValue, - ) => { - const newFormData: UpdateRecordFormData = { - ...formData, - [fieldName]: updatedValue, - }; - - setFormData(newFormData); - - saveAction(newFormData); - }; - - const selectedObjectMetadataItem = activeNonSystemObjectMetadataItems.find( - (item) => item.nameSingular === formData.objectNameSingular, - ); - - const objectNameSingular = selectedObjectMetadataItem?.nameSingular; - - const inlineFieldMetadataItems = selectedObjectMetadataItem?.fields - .filter((fieldMetadataItem) => - shouldDisplayFormField({ fieldMetadataItem, actionType: action.type }), - ) - .sort((fieldMetadataItemA, fieldMetadataItemB) => - fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), - ); - - const inlineFieldDefinitions = isDefined(selectedObjectMetadataItem) - ? inlineFieldMetadataItems?.map((fieldMetadataItem) => - formatFieldMetadataItemAsFieldDefinition({ - field: fieldMetadataItem, - objectMetadataItem: selectedObjectMetadataItem, - showLabel: true, - labelWidth: 90, - }), - ) - : []; - - const saveAction = useDebouncedCallback( - async (formData: UpdateRecordFormData) => { - if (actionOptions.readonly === true) { - return; - } - - const { - objectNameSingular: updatedObjectName, - objectRecordId: updatedObjectRecordId, - fieldsToUpdate: updatedFieldsToUpdate, - ...updatedOtherFields - } = formData; - - actionOptions.onActionUpdate({ - ...action, - settings: { - ...action.settings, - input: { - objectName: updatedObjectName, - objectRecordId: updatedObjectRecordId ?? '', - objectRecord: updatedOtherFields, - fieldsToUpdate: updatedFieldsToUpdate ?? [], - }, - }, - }); - }, - 1_000, - ); - - useEffect(() => { - return () => { - saveAction.flush(); - }; - }, [saveAction]); - const { headerTitle, headerIcon, headerIconColor, headerType } = useWorkflowActionHeader({ action, defaultTitle: 'Update Record', }); + const { getIcon } = useIcons(); + + const isFormDisabled = actionOptions.readonly === true; + + const handleUpdate = (formData: UpdateRecordFormData) => { + if (actionOptions.readonly === true) { + return; + } + + actionOptions.onActionUpdate({ + ...action, + settings: { + ...action.settings, + input: { + objectName: formData.objectNameSingular, + objectRecord: formData, + objectRecordId: formData.objectRecordId ?? '', + fieldsToUpdate: formData.fieldsToUpdate, + }, + }, + }); + }; + return ( <> - - - { + const newFormData: UpdateRecordFormData = { + objectNameSingular: updatedObjectName, + objectRecordId: '', + fieldsToUpdate: [], + }; + + setFormData(newFormData); + + saveAction(newFormData); + }} + withSearchInput + dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }} + dropdownWidth={GenericDropdownContentWidth.ExtraLarge} + /> + + + + {isDefined(objectNameSingular) && shouldPickRecord && ( + + handleFieldChange('objectRecordId', objectRecordId) + } + objectNameSingulars={[objectNameSingular]} + defaultValue={formData.objectRecordId} + disabled={readonly} + VariablePicker={WorkflowVariablePicker} + /> + )} + + {isDefined(selectedObjectMetadataItem) && ( + + handleFieldChange('fieldsToUpdate', fieldsToUpdate) + } + readonly={readonly ?? false} + defaultFields={formData.fieldsToUpdate} + /> + )} + + + + {formData.fieldsToUpdate.map((fieldName) => { + const fieldDefinition = inlineFieldDefinitions?.find((definition) => { + const isFieldRelationManyToOne = + isFieldRelation(definition) && + definition.metadata.relationType === RelationType.MANY_TO_ONE; + + const value = isFieldRelationManyToOne + ? `${definition.metadata.fieldName}Id` + : definition.metadata.fieldName; + + return value === fieldName; + }); + + if (!isDefined(fieldDefinition)) { + return null; + } + + const currentValue = formData[fieldName] as JsonValue; + + return ( + { + handleFieldChange(fieldName, value); + }} + VariablePicker={WorkflowVariablePicker} + readonly={readonly} + /> + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionUpdateRecord.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionUpdateRecord.stories.tsx index d8db021915d..e5295610e0f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionUpdateRecord.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionUpdateRecord.stories.tsx @@ -111,9 +111,7 @@ export const DisabledWithEmptyValues: Story = { } const openRecordSelectButton = within( - await canvas.findByTestId( - 'workflow-edit-action-record-update-object-record-id', - ), + await canvas.findByTestId('workflow-update-record-object-record-id'), ).queryByRole('button'); expect(openRecordSelectButton).not.toBeInTheDocument(); @@ -179,9 +177,7 @@ export const DisabledWithDefaultStaticValues: Story = { expect(selectedRecord).toBeVisible(); const openRecordSelectButton = within( - await canvas.findByTestId( - 'workflow-edit-action-record-update-object-record-id', - ), + await canvas.findByTestId('workflow-update-record-object-record-id'), ).queryByRole('button'); expect(openRecordSelectButton).not.toBeInTheDocument(); @@ -241,9 +237,7 @@ export const DisabledWithDefaultVariableValues: Story = { } const openRecordSelectButton = within( - await canvas.findByTestId( - 'workflow-edit-action-record-update-object-record-id', - ), + await canvas.findByTestId('workflow-update-record-object-record-id'), ).queryByRole('button'); expect(openRecordSelectButton).not.toBeInTheDocument(); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/RecordActions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/RecordActions.ts index 00f79f491b5..a920ea9e9b7 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/RecordActions.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/RecordActions.ts @@ -4,7 +4,11 @@ export const RECORD_ACTIONS: Array<{ label: string; type: Extract< WorkflowActionType, - 'CREATE_RECORD' | 'UPDATE_RECORD' | 'DELETE_RECORD' | 'FIND_RECORDS' + | 'CREATE_RECORD' + | 'UPDATE_RECORD' + | 'DELETE_RECORD' + | 'UPSERT_RECORD' + | 'FIND_RECORDS' >; icon: string; }> = [ @@ -28,4 +32,9 @@ export const RECORD_ACTIONS: Array<{ type: 'FIND_RECORDS', icon: 'IconSearch', }, + { + label: 'Create or Update Record', + type: 'UPSERT_RECORD', + icon: 'IconPencilPlus', + }, ]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type.ts new file mode 100644 index 00000000000..df83f33074d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type.ts @@ -0,0 +1,6 @@ +export type UpdateRecordFormData = { + objectNameSingular: string; + objectRecordId?: string; + fieldsToUpdate: string[]; + [field: string]: unknown; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts index 46d201d5da8..91ea50a3c0c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts @@ -9,6 +9,7 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => { case 'CREATE_RECORD': case 'UPDATE_RECORD': case 'DELETE_RECORD': + case 'UPSERT_RECORD': case 'FIND_RECORDS': case 'FORM': case 'SEND_EMAIL': diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts index ada6bb3dfb1..fed91974631 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts @@ -10,6 +10,7 @@ export const getActionIcon = (actionType: WorkflowActionType) => { case 'CREATE_RECORD': case 'UPDATE_RECORD': case 'DELETE_RECORD': + case 'UPSERT_RECORD': case 'FIND_RECORDS': return RECORD_ACTIONS.find((item) => item.type === actionType)?.icon; case 'AI_AGENT': diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts index be0087ec536..584a7af7830 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts @@ -17,6 +17,7 @@ export const getActionIconColorOrThrow = ({ case 'CREATE_RECORD': case 'UPDATE_RECORD': case 'DELETE_RECORD': + case 'UPSERT_RECORD': case 'FIND_RECORDS': return theme.font.color.tertiary; case 'FORM': diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField.ts index 2852628d759..c56b03f54c4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField.ts @@ -48,6 +48,7 @@ export const shouldDisplayFormField = ({ fieldMetadataItem.isActive ); case 'UPDATE_RECORD': + case 'UPSERT_RECORD': isTypeAllowedForAction = COMMON_DISPLAYABLE_FIELD_TYPES.includes(fieldMetadataItem.type) || fieldMetadataItem.settings?.['relationType'] === 'MANY_TO_ONE'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isRecordStepOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isRecordStepOutputSchema.ts index 5a11688b5a5..bf11da279b7 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isRecordStepOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isRecordStepOutputSchema.ts @@ -5,7 +5,12 @@ import { import { type RecordActionOutputSchema } from '@/workflow/workflow-variables/types/RecordActionOutputSchema'; import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; -const RECORD_STEP_TYPES = ['CREATE_RECORD', 'UPDATE_RECORD', 'DELETE_RECORD']; +const RECORD_STEP_TYPES = [ + 'CREATE_RECORD', + 'UPDATE_RECORD', + 'DELETE_RECORD', + 'UPSERT_RECORD', +]; export const isRecordStepOutputSchema = ( stepType: WorkflowActionType | WorkflowTriggerType, diff --git a/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts b/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts index cdc072301ab..35f7d33f966 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts @@ -5,7 +5,6 @@ import { isDefined } from 'twenty-shared/utils'; import { type UpdateWorkflowVersionPositionsMutation, type UpdateWorkflowVersionPositionsMutationVariables, - type WorkflowAction, } from '~/generated-metadata/graphql'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; @@ -57,7 +56,7 @@ export const useTidyUpWorkflowVersion = () => { ? { ...cachedRecord.trigger, position: triggerPosition.position } : cachedRecord.trigger; - const updatedSteps = cachedRecord.steps?.map((step: WorkflowAction) => { + const updatedSteps = cachedRecord.steps?.map((step) => { const stepPosition = positions.find( (position) => position.id === step.id, ); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/exceptions/record-crud.exception.ts b/packages/twenty-server/src/engine/core-modules/record-crud/exceptions/record-crud.exception.ts index 7acf18bd8e9..0c5b122bf39 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/exceptions/record-crud.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/exceptions/record-crud.exception.ts @@ -15,5 +15,6 @@ export enum RecordCrudExceptionCode { RECORD_CREATION_FAILED = 'RECORD_CREATION_FAILED', RECORD_UPDATE_FAILED = 'RECORD_UPDATE_FAILED', RECORD_DELETION_FAILED = 'RECORD_DELETION_FAILED', + RECORD_UPSERT_FAILED = 'RECORD_UPSERT_FAILED', QUERY_FAILED = 'QUERY_FAILED', } diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts index 039a45b618a..56d4c8b3f77 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts @@ -4,6 +4,7 @@ import { CreateRecordService } from 'src/engine/core-modules/record-crud/service import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; +import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; @@ -21,12 +22,14 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo UpdateRecordService, DeleteRecordService, FindRecordsService, + UpsertRecordService, ], exports: [ CreateRecordService, UpdateRecordService, DeleteRecordService, FindRecordsService, + UpsertRecordService, ], }) export class RecordCrudModule {} diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts new file mode 100644 index 00000000000..bdc248ba635 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-record.service.ts @@ -0,0 +1,194 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; + +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { UpsertRecordParams } from 'src/engine/core-modules/record-crud/types/upsert-record-params.type'; +import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { getCompositeTypeOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/get-composite-type-or-throw.util'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class UpsertRecordService { + private readonly logger = new Logger(UpsertRecordService.name); + + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly recordInputTransformerService: RecordInputTransformerService, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + ) {} + + async execute(params: UpsertRecordParams): Promise { + const { + objectName, + objectRecord, + fieldsToUpdate, + workspaceId, + rolePermissionConfig, + } = params; + + if (!workspaceId) { + return { + success: false, + message: 'Failed to upsert record: Workspace ID is required', + error: 'Workspace ID not found', + }; + } + + try { + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + objectName, + rolePermissionConfig, + ); + + const fieldsToUpdateArray = fieldsToUpdate || Object.keys(objectRecord); + + const { objectMetadataItemWithFieldsMaps } = + await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( + objectName, + workspaceId, + ); + + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, + isSystem: objectMetadataItemWithFieldsMaps.isSystem, + }) + ) { + throw new RecordCrudException( + 'Failed to update: Object cannot be updated by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const objectRecordWithFilteredFields = Object.keys(objectRecord).reduce( + (acc, key) => { + if (fieldsToUpdateArray.includes(key)) { + return { + ...acc, + [key]: objectRecord[key], + }; + } + + return acc; + }, + {}, + ); + + const transformedObjectRecord = + await this.recordInputTransformerService.process({ + recordInput: objectRecordWithFilteredFields, + objectMetadataMapItem: objectMetadataItemWithFieldsMaps, + }); + + const uniqueFieldsToUpdate = fieldsToUpdateArray + .map((field) => objectMetadataItemWithFieldsMaps.fieldIdByName[field]) + .map((fieldId) => objectMetadataItemWithFieldsMaps.fieldsById[fieldId]) + .filter((field) => field.isUnique || field.name === 'id'); + + const conflictPathsUniqueFieldsToUpdate = uniqueFieldsToUpdate.flatMap( + (field) => { + if (isCompositeFieldMetadataType(field.type)) { + const compositeType = getCompositeTypeOrThrow(field.type); + + const uniqueProperties = compositeType.properties.filter( + (prop) => prop.isIncludedInUniqueConstraint, + ); + + const propertiesToUse = + uniqueProperties.length > 0 + ? uniqueProperties + : [compositeType.properties[0]]; + + return propertiesToUse.map((prop) => + computeCompositeColumnName(field, prop), + ); + } + + return [field.name]; + }, + ); + + const conflictPaths = + conflictPathsUniqueFieldsToUpdate.length > 0 + ? conflictPathsUniqueFieldsToUpdate + : ['id']; + + const indexPredicate = uniqueFieldsToUpdate + .map((field) => + computeUniqueIndexWhereClause({ + type: field.type, + name: field.name, + }), + ) + .filter(isDefined); + + const upsertResult = await repository.upsert(transformedObjectRecord, { + conflictPaths: conflictPaths, + indexPredicate: + indexPredicate.length > 0 + ? `${indexPredicate.join(' AND ')}` + : undefined, + }); + + const upsertedRecordId = upsertResult.identifiers?.[0].id; + + if (!isDefined(upsertedRecordId)) { + throw new RecordCrudException( + `Failed to upsert record in ${objectName}`, + RecordCrudExceptionCode.RECORD_UPSERT_FAILED, + ); + } + + const upsertedRecord = await repository.findOne({ + where: { + id: upsertedRecordId, + }, + }); + + if (!upsertedRecord) { + throw new RecordCrudException( + `Record not found after upsert with id ${upsertedRecordId} in ${objectName}`, + RecordCrudExceptionCode.RECORD_UPSERT_FAILED, + ); + } + + this.logger.log(`Record upserted successfully in ${objectName}`); + + return { + success: true, + message: `Record upserted successfully in ${objectName}`, + result: upsertedRecord, + }; + } catch (error) { + if (error instanceof RecordCrudException) { + return { + success: false, + message: `Failed to upsert record in ${objectName}`, + error: error.message, + }; + } + + this.logger.error(`Failed to upsert record: ${error}`); + + return { + success: false, + message: `Failed to upsert record in ${objectName}`, + error: + error instanceof Error ? error.message : 'Failed to upsert record', + }; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/upsert-record-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/upsert-record-params.type.ts new file mode 100644 index 00000000000..7995ab84dab --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/upsert-record-params.type.ts @@ -0,0 +1,10 @@ +import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +export type UpsertRecordParams = { + objectName: string; + objectRecord: ObjectRecordProperties; + fieldsToUpdate?: string[]; + workspaceId: string; + rolePermissionConfig?: RolePermissionConfig; +}; 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 1f88d200dcb..535db6abded 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 @@ -1,10 +1,14 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import graphqlTypeJson from 'graphql-type-json'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; 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'; + +registerEnumType(WorkflowActionType, { + name: 'WorkflowActionType', +}); @ObjectType('WorkflowAction') export class WorkflowActionDTO { @@ -14,7 +18,7 @@ export class WorkflowActionDTO { @Field(() => String) name: string; - @Field(() => String) + @Field(() => WorkflowActionType) type: WorkflowActionType; @Field(() => graphqlTypeJson) diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index 6d415666f1a..7e0503a50f8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -266,7 +266,20 @@ export class WorkspaceEntityManager extends EntityManager { ), ); - return this.createQueryBuilder( + const overwrites = [...conflictColumns, ...overwriteColumns].map( + (col) => col.databaseName, + ); + + const conflictTargets = conflictColumns.map((col) => col.databaseName); + + const upsertOptions = { + skipUpdateIfNoValuesChanged: options.skipUpdateIfNoValuesChanged, + indexPredicate: options.indexPredicate, + upsertType: + options.upsertType || this.connection.driver.supportedUpsertTypes[0], + }; + + const queryBuilder = this.createQueryBuilder( undefined, undefined, undefined, @@ -275,21 +288,10 @@ export class WorkspaceEntityManager extends EntityManager { .insert() .into(target) .values(entities) - .orUpdate( - [...conflictColumns, ...overwriteColumns].map( - (col) => col.databaseName, - ), - conflictColumns.map((col) => col.databaseName), - { - skipUpdateIfNoValuesChanged: options.skipUpdateIfNoValuesChanged, - indexPredicate: options.indexPredicate, - upsertType: - options.upsertType || - this.connection.driver.supportedUpsertTypes[0], - }, - ) - .returning(selectedColumns) - .execute(); + .orUpdate(overwrites, conflictTargets, upsertOptions) + .returning(selectedColumns); + + return queryBuilder.execute(); } override update( 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 50058bbe95e..8fc2f6b2aa3 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 @@ -84,6 +84,7 @@ export class WorkflowSchemaWorkspaceService { case WorkflowActionType.CREATE_RECORD: case WorkflowActionType.UPDATE_RECORD: case WorkflowActionType.DELETE_RECORD: + case WorkflowActionType.UPSERT_RECORD: return this.computeRecordOutputSchema({ objectType: step.settings.input.objectName, workspaceId, 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 ca07389e21f..b3e3e85d1c4 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 @@ -245,6 +245,28 @@ export class WorkflowVersionStepOperationsWorkspaceService { }, }; } + case WorkflowActionType.UPSERT_RECORD: { + const activeObjectMetadataItem = + await this.objectMetadataRepository.findOne({ + where: { workspaceId, isActive: true, isSystem: false }, + }); + + return { + builtStep: { + ...baseStep, + name: 'Create or Update Record', + type: WorkflowActionType.UPSERT_RECORD, + settings: { + ...BASE_STEP_DEFINITION, + input: { + objectName: activeObjectMetadataItem?.nameSingular || '', + objectRecord: {}, + fieldsToUpdate: [], + }, + }, + }, + }; + } case WorkflowActionType.FIND_RECORDS: { const activeObjectMetadataItem = await this.objectMetadataRepository.findOne({ 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 098ebec0240..7ef0cb9c369 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 @@ -17,6 +17,7 @@ import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-execut import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action'; 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 { ToolExecutorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action'; import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; @@ -25,6 +26,7 @@ export class WorkflowActionFactory { constructor( private readonly codeWorkflowAction: CodeWorkflowAction, private readonly createRecordWorkflowAction: CreateRecordWorkflowAction, + private readonly upsertRecordWorkflowAction: UpsertRecordWorkflowAction, private readonly updateRecordWorkflowAction: UpdateRecordWorkflowAction, private readonly deleteRecordWorkflowAction: DeleteRecordWorkflowAction, private readonly findRecordsWorkflowAction: FindRecordsWorkflowAction, @@ -45,6 +47,8 @@ export class WorkflowActionFactory { return this.toolExecutorWorkflowAction; case WorkflowActionType.CREATE_RECORD: return this.createRecordWorkflowAction; + case WorkflowActionType.UPSERT_RECORD: + return this.upsertRecordWorkflowAction; case WorkflowActionType.UPDATE_RECORD: return this.updateRecordWorkflowAction; case WorkflowActionType.DELETE_RECORD: 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 new file mode 100644 index 00000000000..f0d3b42f77d --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-upsert-record-action.guard.ts @@ -0,0 +1,11 @@ +import { + type WorkflowAction, + WorkflowActionType, + type WorkflowUpsertRecordAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const isWorkflowUpsertRecordAction = ( + action: WorkflowAction, +): action is WorkflowUpsertRecordAction => { + return action.type === WorkflowActionType.UPSERT_RECORD; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts index 366d95b3a96..92596b67bb2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module.ts @@ -4,11 +4,13 @@ import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-cru import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action'; import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action'; 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 { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module'; @Module({ @@ -17,17 +19,20 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow WorkflowRunModule, UserWorkspaceModule, UserRoleModule, + WorkflowCommonModule, ], providers: [ ScopedWorkspaceContextFactory, WorkflowExecutionContextService, CreateRecordWorkflowAction, + UpsertRecordWorkflowAction, UpdateRecordWorkflowAction, DeleteRecordWorkflowAction, FindRecordsWorkflowAction, ], exports: [ CreateRecordWorkflowAction, + UpsertRecordWorkflowAction, UpdateRecordWorkflowAction, DeleteRecordWorkflowAction, FindRecordsWorkflowAction, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts index 7900d423546..650e9550cb9 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts @@ -8,6 +8,7 @@ import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud export type WorkflowCreateRecordActionInput = { objectName: string; objectRecord: ObjectRecordProperties; + upsert?: boolean; }; export type WorkflowUpdateRecordActionInput = { @@ -35,3 +36,9 @@ export type WorkflowFindRecordsActionInput = { orderBy?: Partial; limit?: number; }; + +export type WorkflowUpsertRecordActionInput = { + objectName: string; + objectRecord: ObjectRecordProperties; + fieldsToUpdate?: string[]; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type.ts index b6e7ff448d4..74a4e5564fe 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type.ts @@ -1,4 +1,5 @@ import { + type WorkflowUpsertRecordActionInput, type WorkflowCreateRecordActionInput, type WorkflowDeleteRecordActionInput, type WorkflowFindRecordsActionInput, @@ -21,3 +22,7 @@ export type WorkflowDeleteRecordActionSettings = BaseWorkflowActionSettings & { export type WorkflowFindRecordsActionSettings = BaseWorkflowActionSettings & { input: WorkflowFindRecordsActionInput; }; + +export type WorkflowUpsertRecordActionSettings = BaseWorkflowActionSettings & { + input: WorkflowUpsertRecordActionInput; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action.ts new file mode 100644 index 00000000000..ee0cc63b0c6 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined, resolveInput } from 'twenty-shared/utils'; + +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; + +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service'; +import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { + WorkflowStepExecutorException, + WorkflowStepExecutorExceptionCode, +} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; +import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; +import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; +import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; +import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; +import { isWorkflowUpsertRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-upsert-record-action.guard'; +import { WorkflowUpsertRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; + +@Injectable() +export class UpsertRecordWorkflowAction implements WorkflowAction { + constructor( + private readonly upsertRecordService: UpsertRecordService, + private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, + private readonly workflowExecutionContextService: WorkflowExecutionContextService, + ) {} + + async execute({ + currentStepId, + steps, + context, + runInfo, + }: WorkflowActionInput): Promise { + const step = findStepOrThrow({ + steps, + stepId: currentStepId, + }); + + if (!isWorkflowUpsertRecordAction(step)) { + throw new WorkflowStepExecutorException( + 'Step is not an upsert record action', + WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE, + ); + } + + const workflowActionInput = resolveInput( + step.settings.input, + context, + ) as WorkflowUpsertRecordActionInput; + + if (!isDefined(workflowActionInput.objectName)) { + throw new RecordCrudException( + 'Failed to upsert: Object name is required', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const { workspaceId } = this.scopedWorkspaceContextFactory.create(); + + if (!workspaceId) { + throw new RecordCrudException( + 'Failed to update: Workspace ID is required', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const executionContext = + await this.workflowExecutionContextService.getExecutionContext(runInfo); + + const toolOutput = await this.upsertRecordService.execute({ + objectName: workflowActionInput.objectName, + objectRecord: workflowActionInput.objectRecord, + fieldsToUpdate: workflowActionInput.fieldsToUpdate, + workspaceId, + rolePermissionConfig: executionContext.rolePermissionConfig, + }); + + if (!toolOutput.success) { + throw new RecordCrudException( + toolOutput.error || toolOutput.message, + RecordCrudExceptionCode.RECORD_UPSERT_FAILED, + ); + } + + return { + result: toolOutput.result, + }; + } +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts index 6034c54a52d..c2ffff542f6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type.ts @@ -12,6 +12,7 @@ import { type WorkflowDeleteRecordActionSettings, type WorkflowFindRecordsActionSettings, type WorkflowUpdateRecordActionSettings, + type WorkflowUpsertRecordActionSettings, } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type'; export type BaseWorkflowActionSettings = { @@ -32,6 +33,7 @@ export type WorkflowActionSettings = | WorkflowCreateRecordActionSettings | WorkflowUpdateRecordActionSettings | WorkflowDeleteRecordActionSettings + | WorkflowUpsertRecordActionSettings | WorkflowFindRecordsActionSettings | WorkflowFormActionSettings | WorkflowFilterActionSettings diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts index cb0115298fe..5462384607a 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 @@ -6,6 +6,7 @@ import { type WorkflowHttpRequestActionSettings } from 'src/modules/workflow/wor import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type'; import { type WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type'; import { + type WorkflowUpsertRecordActionSettings, type WorkflowCreateRecordActionSettings, type WorkflowDeleteRecordActionSettings, type WorkflowFindRecordsActionSettings, @@ -20,6 +21,7 @@ export enum WorkflowActionType { CREATE_RECORD = 'CREATE_RECORD', UPDATE_RECORD = 'UPDATE_RECORD', DELETE_RECORD = 'DELETE_RECORD', + UPSERT_RECORD = 'UPSERT_RECORD', FIND_RECORDS = 'FIND_RECORDS', FORM = 'FORM', FILTER = 'FILTER', @@ -68,6 +70,11 @@ export type WorkflowDeleteRecordAction = BaseWorkflowAction & { settings: WorkflowDeleteRecordActionSettings; }; +export type WorkflowUpsertRecordAction = BaseWorkflowAction & { + type: WorkflowActionType.UPSERT_RECORD; + settings: WorkflowUpsertRecordActionSettings; +}; + export type WorkflowFindRecordsAction = BaseWorkflowAction & { type: WorkflowActionType.FIND_RECORDS; settings: WorkflowFindRecordsActionSettings; @@ -113,6 +120,7 @@ export type WorkflowAction = | WorkflowCreateRecordAction | WorkflowUpdateRecordAction | WorkflowDeleteRecordAction + | WorkflowUpsertRecordAction | WorkflowFindRecordsAction | WorkflowFormAction | WorkflowFilterAction diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 49a99171f27..e5a23014a09 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -41,6 +41,8 @@ export { workflowSendEmailActionSchema } from './schemas/send-email-action-schem export { workflowSendEmailActionSettingsSchema } from './schemas/send-email-action-settings-schema'; export { workflowUpdateRecordActionSchema } from './schemas/update-record-action-schema'; export { workflowUpdateRecordActionSettingsSchema } from './schemas/update-record-action-settings-schema'; +export { workflowUpsertRecordActionSchema } from './schemas/upsert-record-action-schema'; +export { workflowUpsertRecordActionSettingsSchema } from './schemas/upsert-record-action-settings-schema'; export { workflowWebhookTriggerSchema } from './schemas/webhook-trigger-schema'; export { workflowActionSchema } from './schemas/workflow-action-schema'; export { workflowDelayActionSchema } from './schemas/workflow-delay-action-schema'; diff --git a/packages/twenty-shared/src/workflow/schemas/upsert-record-action-schema.ts b/packages/twenty-shared/src/workflow/schemas/upsert-record-action-schema.ts new file mode 100644 index 00000000000..d7e7913002b --- /dev/null +++ b/packages/twenty-shared/src/workflow/schemas/upsert-record-action-schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { baseWorkflowActionSchema } from './base-workflow-action-schema'; +import { workflowUpsertRecordActionSettingsSchema } from './upsert-record-action-settings-schema'; + +export const workflowUpsertRecordActionSchema = baseWorkflowActionSchema.extend( + { + type: z.literal('UPSERT_RECORD'), + settings: workflowUpsertRecordActionSettingsSchema, + }, +); diff --git a/packages/twenty-shared/src/workflow/schemas/upsert-record-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/upsert-record-action-settings-schema.ts new file mode 100644 index 00000000000..814fbb07cc1 --- /dev/null +++ b/packages/twenty-shared/src/workflow/schemas/upsert-record-action-settings-schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema'; +import { objectRecordSchema } from './object-record-schema'; + +export const workflowUpsertRecordActionSettingsSchema = + baseWorkflowActionSettingsSchema.extend({ + input: z.object({ + objectName: z.string(), + objectRecord: objectRecordSchema, + fieldsToUpdate: z.array(z.string()), + }), + }); diff --git a/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts b/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts index fd06485432e..28bb6a3cdf8 100644 --- a/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/workflow-action-schema.ts @@ -11,6 +11,7 @@ import { workflowHttpRequestActionSchema } from './http-request-action-schema'; import { workflowIteratorActionSchema } from './iterator-action-schema'; import { workflowSendEmailActionSchema } from './send-email-action-schema'; import { workflowUpdateRecordActionSchema } from './update-record-action-schema'; +import { workflowUpsertRecordActionSchema } from './upsert-record-action-schema'; import { workflowDelayActionSchema } from './workflow-delay-action-schema'; export const workflowActionSchema = z.discriminatedUnion('type', [ @@ -19,6 +20,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [ workflowCreateRecordActionSchema, workflowUpdateRecordActionSchema, workflowDeleteRecordActionSchema, + workflowUpsertRecordActionSchema, workflowFindRecordsActionSchema, workflowFormActionSchema, workflowHttpRequestActionSchema,