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
---------
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 (
<>
-
-
-
+
{!actionOptions.readonly && }
>
);
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx
new file mode 100644
index 00000000000..3d4389eed2b
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx
@@ -0,0 +1,85 @@
+import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
+import { type WorkflowUpsertRecordAction } from '@/workflow/types/Workflow';
+import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
+import { WorkflowUpdateRecordBody } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowUpdateRecordBody';
+
+import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
+import { type UpdateRecordFormData } from '@/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type';
+import { useIcons } from 'twenty-ui/display';
+
+type WorkflowEditActionUpsertRecordProps = {
+ action: WorkflowUpsertRecordAction;
+ actionOptions:
+ | {
+ readonly: true;
+ }
+ | {
+ readonly?: false;
+ onActionUpdate: (action: WorkflowUpsertRecordAction) => void;
+ };
+};
+
+export const WorkflowEditActionUpsertRecord = ({
+ action,
+ actionOptions,
+}: WorkflowEditActionUpsertRecordProps) => {
+ const { headerTitle, headerIcon, headerIconColor, headerType } =
+ useWorkflowActionHeader({
+ action,
+ defaultTitle: 'Create or 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,
+ fieldsToUpdate: formData.fieldsToUpdate,
+ },
+ },
+ });
+ };
+
+ return (
+ <>
+ {
+ if (actionOptions.readonly === true) {
+ return;
+ }
+
+ actionOptions.onActionUpdate({
+ ...action,
+ name: newName,
+ });
+ }}
+ Icon={getIcon(headerIcon)}
+ iconColor={headerIconColor}
+ initialTitle={headerTitle}
+ headerType={headerType}
+ disabled={isFormDisabled}
+ />
+
+ {!actionOptions.readonly && }
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowUpdateRecordBody.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowUpdateRecordBody.tsx
new file mode 100644
index 00000000000..f14926fb2cc
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowUpdateRecordBody.tsx
@@ -0,0 +1,232 @@
+import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
+import { Select } from '@/ui/input/components/Select';
+import { useEffect, useState } from 'react';
+
+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 { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
+import { type UpdateRecordFormData } from '@/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type';
+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';
+
+type WorkflowUpdateRecordBodyProps = {
+ defaultObjectNameSingular: string;
+ defaultFieldsToUpdate: string[];
+ defaultObjectRecord: Record;
+ defaultObjectRecordId?: string;
+ actionType: 'UPDATE_RECORD' | 'UPSERT_RECORD';
+ readonly: boolean;
+ onUpdate: (formData: UpdateRecordFormData) => void;
+ shouldPickRecord: boolean;
+};
+
+export const WorkflowUpdateRecordBody = ({
+ defaultObjectNameSingular,
+ defaultFieldsToUpdate,
+ defaultObjectRecord,
+ defaultObjectRecordId,
+ readonly,
+ actionType,
+ onUpdate,
+ shouldPickRecord,
+}: WorkflowUpdateRecordBodyProps) => {
+ 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: defaultObjectNameSingular,
+ objectRecordId: defaultObjectRecordId,
+ fieldsToUpdate: defaultFieldsToUpdate ?? [],
+ ...defaultObjectRecord,
+ });
+
+ 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 }),
+ )
+ .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 (readonly === true) {
+ return;
+ }
+
+ const {
+ objectNameSingular: updatedObjectName,
+ objectRecordId: updatedObjectRecordId,
+ fieldsToUpdate: updatedFieldsToUpdate,
+ ...updatedOtherFields
+ } = formData;
+
+ onUpdate({
+ objectNameSingular: updatedObjectName,
+ objectRecordId: updatedObjectRecordId ?? '',
+ fieldsToUpdate: updatedFieldsToUpdate ?? [],
+ ...updatedOtherFields,
+ });
+ },
+ 1_000,
+ );
+
+ useEffect(() => {
+ return () => {
+ saveAction.flush();
+ };
+ }, [saveAction]);
+
+ return (
+
+
+ );
+};
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,