From 4be76e3fd18a5c041958b287937ee49aeab38cdc Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:56:17 +0200 Subject: [PATCH] Support morph relations in workflow record nodes (#21403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Support morph (polymorphic) relations in workflow record nodes Morph relations (e.g. a polymorphic `Owner` on `Pet` targeting `Person` or `Company`) were not selectable in the workflow **Create / Update / Upsert Record** nodes. This PR adds full support for setting them. ### What changed **Frontend** - `shouldDisplayFormField`: allow `MORPH_RELATION` (many-to-one) so morph fields appear in record forms. - New `FormMorphRelationToOneFieldInput`: a polymorphic record picker across the morph's target objects, storing a self-describing value `{ targetObjectMetadataId, id }`. - Wired the morph branch into `FormFieldInput`. **Backend** - New `formatWorkflowRecordMorphRelationFields` util: resolves the form value (stored under the base field name, e.g. `owner`) into the correct per-target join column (`ownerCompanyId`), nulling siblings to keep exactly one target referenced. - Wired into the create / update / upsert workflow actions (update also expands `fieldsToUpdate` to the concrete join columns). ### Permissions handling - The picker's search is scoped to only the morph targets the user can read (`canReadObjectRecords`), so it no longer breaks when a target object is inaccessible. - If an existing value points to an object the user can't read, the field shows the reused **"Not shared"** lock display instead of an empty field, while remaining editable when other targets are readable. ### Notes - No data schema / migration changes — reuses the existing per-target morph columns and stores the selection in the existing workflow step JSON settings. Screenshot 2026-06-10 at 14 57 40 Also handles the case where the selected record is not readable image --- .../ui/components/FormFieldInput.tsx | 15 ++ .../FormMorphRelationToOneFieldInput.tsx | 172 ++++++++++++++++++ .../components/FormSingleRecordPicker.tsx | 18 +- .../types/VariablePickerComponent.ts | 1 + .../WorkflowEditActionCreateRecord.tsx | 12 ++ .../WorkflowEditActionUpdateRecord.tsx | 12 ++ .../WorkflowEditActionUpsertRecord.tsx | 15 ++ .../utils/shouldDisplayFormField.ts | 5 +- .../components/WorkflowVariablePicker.tsx | 2 + .../components/WorkflowVariablesDropdown.tsx | 3 + .../WorkflowVariablesDropdownStepItems.tsx | 14 +- .../core-modules/search/search.resolver.ts | 7 +- ...at-workflow-record-relation-fields.util.ts | 163 ++++++++++++++++- .../create-record.workflow-action.ts | 9 +- .../update-record.workflow-action.ts | 11 +- .../upsert-record.workflow-action.ts | 9 +- 16 files changed, 450 insertions(+), 18 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMorphRelationToOneFieldInput.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx index fbbcaf5f10..af8aa58090 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx @@ -8,6 +8,10 @@ import { FormEmailsFieldInput } from '@/object-record/record-field/ui/form-types import { FormFilesFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFilesFieldInput'; import { FormFullNameFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFullNameFieldInput'; import { FormLinksFieldInput } from '@/object-record/record-field/ui/form-types/components/FormLinksFieldInput'; +import { + FormMorphRelationToOneFieldInput, + type FormMorphRelationToOneValue, +} from '@/object-record/record-field/ui/form-types/components/FormMorphRelationToOneFieldInput'; import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput'; import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput'; import { FormPhoneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormPhoneFieldInput'; @@ -43,6 +47,7 @@ import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFi import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles'; import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName'; import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks'; +import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne'; import { isFieldMultiSelect } from '@/object-record/record-field/ui/types/guards/isFieldMultiSelect'; import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber'; import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones'; @@ -241,6 +246,16 @@ export const FormFieldInput = ({ VariablePicker={VariablePicker} readonly={readonly} /> + ) : isFieldMorphRelationManyToOne(field) ? ( + ) : isFieldArray(field) ? ( void; + onClear?: () => void; + readonly?: boolean; + testId?: string; + VariablePicker?: VariablePickerComponent; +}; + +export const FormMorphRelationToOneFieldInput = ({ + label, + morphRelations, + defaultValue, + onChange, + onClear, + readonly, + testId, + VariablePicker, +}: FormMorphRelationToOneFieldInputProps) => { + const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const componentId = useId(); + + const readableObjectNameSingulars = [ + ...new Set( + morphRelations + .filter( + (morphRelation) => + objectPermissionsByObjectMetadataId[ + morphRelation.targetObjectMetadata.id + ]?.canReadObjectRecords === true, + ) + .map( + (morphRelation) => morphRelation.targetObjectMetadata.nameSingular, + ), + ), + ]; + + const selectedMorphValue = + isDefined(defaultValue) && !isString(defaultValue) ? defaultValue : null; + + const recordIdOrVariable: RecordId | Variable | null | undefined = isString( + defaultValue, + ) + ? defaultValue + : isDefined(defaultValue) + ? defaultValue.id + : defaultValue; + + const selectedTargetIsReadable = + !isDefined(selectedMorphValue) || + objectPermissionsByObjectMetadataId[ + selectedMorphValue.targetObjectMetadataId + ]?.canReadObjectRecords === true; + + const hasForbiddenSelectedRecord = + isDefined(selectedMorphValue) && !selectedTargetIsReadable; + + const selectedObjectMetadataItem = isDefined(selectedMorphValue) + ? objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === selectedMorphValue.targetObjectMetadataId, + ) + : undefined; + + const selectedObjectNameSingular = + selectedObjectMetadataItem?.nameSingular ?? readableObjectNameSingulars[0]; + + if (hasForbiddenSelectedRecord) { + return ( + + {label ? {label} : null} + + + + + + + + + + + ); + } + + if (readableObjectNameSingulars.length === 0) { + return ( + + {label ? {label} : null} + + + + {t`No record`} + + + + + ); + } + + return ( + + onChange({ + targetObjectMetadataId: selectedMorphItem.objectMetadataId, + id: selectedMorphItem.recordId, + }) + } + disabled={readonly} + VariablePicker={VariablePicker} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordPicker.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordPicker.tsx index ec2d5e0014..17caa7bcc5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordPicker.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordPicker.tsx @@ -60,6 +60,10 @@ export type FormSingleRecordPickerProps = { onClear?: () => void; onCreate?: (searchInput?: string) => void | Promise; objectNameSingulars: string[]; + selectedObjectNameSingular?: string; + onMorphItemSelected?: ( + selectedMorphItem: RecordPickerPickableMorphItem, + ) => void; disabled?: boolean; testId?: string; VariablePicker?: VariablePickerComponent; @@ -69,8 +73,10 @@ export const FormSingleRecordPicker = ({ label, defaultValue, objectNameSingulars, + selectedObjectNameSingular, onChange, onClear, + onMorphItemSelected, onCreate, disabled, testId, @@ -78,6 +84,9 @@ export const FormSingleRecordPicker = ({ }: FormSingleRecordPickerProps) => { const { theme } = useContext(ThemeContext); + const resolvedObjectNameSingular = + selectedObjectNameSingular ?? objectNameSingulars[0]; + const draftValue: FormSingleRecordPickerValue = defaultValue === null ? { type: 'no-record', value: null } @@ -97,7 +106,7 @@ export const FormSingleRecordPicker = ({ isDefined(defaultValue) && !isStandaloneVariableString(defaultValue) ? defaultValue : '', - objectNameSingular: objectNameSingulars[0], + objectNameSingular: resolvedObjectNameSingular, withSoftDeleted: true, skip: !isDefined(defaultValue) || !isValidUuid(defaultValue), }); @@ -133,6 +142,8 @@ export const FormSingleRecordPicker = ({ if (defaultValue === selectedMorphItem.recordId) { onClear?.(); + } else if (isDefined(onMorphItemSelected)) { + onMorphItemSelected(selectedMorphItem); } else { onChange(selectedMorphItem.recordId); } @@ -185,7 +196,7 @@ export const FormSingleRecordPicker = ({ @@ -212,7 +223,7 @@ export const FormSingleRecordPicker = ({ @@ -248,6 +259,7 @@ export const FormSingleRecordPicker = ({ onVariableSelect={handleVariableTagInsert} shouldDisplayRecordObjects={true} shouldDisplayRecordFields={false} + objectNameSingularsToSelect={objectNameSingulars} /> )} diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/types/VariablePickerComponent.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/types/VariablePickerComponent.ts index 5cab4c1a63..f4807aa459 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/types/VariablePickerComponent.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/types/VariablePickerComponent.ts @@ -5,4 +5,5 @@ export type VariablePickerComponent = React.FC<{ onVariableSelect: (variableName: string) => void; shouldDisplayRecordObjects?: boolean; shouldDisplayRecordFields?: boolean; + objectNameSingularsToSelect?: string[]; }>; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx index 5fe610e4f2..c1b0eea645 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx @@ -166,6 +166,15 @@ export const WorkflowEditActionCreateRecord = ({ saveAction(newFormData); }; + const handleFieldClear = (fieldName: keyof CreateRecordFormData) => { + const newFormData: CreateRecordFormData = { ...formData }; + delete newFormData[fieldName]; + + setFormData(newFormData); + + saveAction(newFormData); + }; + const saveAction = useDebouncedCallback( async (formData: CreateRecordFormData) => { if (actionOptions.readonly === true) { @@ -242,6 +251,9 @@ export const WorkflowEditActionCreateRecord = ({ onChange={(value) => { handleFieldChange(fieldDefinition.metadata.fieldName, value); }} + onClear={() => { + handleFieldClear(fieldDefinition.metadata.fieldName); + }} VariablePicker={WorkflowVariablePicker} readonly={isFormDisabled} /> 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 a7d609b846..335b440197 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 @@ -80,6 +80,15 @@ export const WorkflowEditActionUpdateRecord = ({ saveAction(newFormData); }; + const handleFieldClear = (fieldName: keyof UpdateRecordFormData) => { + const newFormData: UpdateRecordFormData = { ...formData }; + delete newFormData[fieldName]; + + setFormData(newFormData); + + saveAction(newFormData); + }; + const selectedObjectMetadataItem = activeNonSystemObjectMetadataItems.find( (item) => item.nameSingular === formData.objectNameSingular, ); @@ -229,6 +238,9 @@ export const WorkflowEditActionUpdateRecord = ({ onChange={(value) => { handleFieldChange(fieldName, value); }} + onClear={() => { + handleFieldClear(fieldName); + }} VariablePicker={WorkflowVariablePicker} readonly={isFormDisabled} /> 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 index 7f5a02b38c..270c08ab50 100644 --- 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 @@ -183,6 +183,15 @@ export const WorkflowEditActionUpsertRecord = ({ saveAction(newFormData); }; + const handleFieldClear = (fieldName: keyof UpsertRecordFormData) => { + const newFormData: UpsertRecordFormData = { ...formData }; + delete newFormData[fieldName]; + + setFormData(newFormData); + + saveAction(newFormData); + }; + const saveAction = useDebouncedCallback( async (formData: UpsertRecordFormData) => { if (actionOptions.readonly === true) { @@ -269,6 +278,9 @@ export const WorkflowEditActionUpsertRecord = ({ onChange={(recordId) => { handleFieldChange('id', recordId); }} + onClear={() => { + handleFieldClear('id'); + }} objectNameSingulars={ isDefined(objectNameSingular) ? [objectNameSingular] : [] } @@ -299,6 +311,9 @@ export const WorkflowEditActionUpsertRecord = ({ onChange={(value) => { handleFieldChange(fieldDefinition.metadata.fieldName, value); }} + onClear={() => { + handleFieldClear(fieldDefinition.metadata.fieldName); + }} VariablePicker={WorkflowVariablePicker} readonly={isFormDisabled} /> 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 cdf553d4b4..3b8856e67b 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 @@ -22,6 +22,7 @@ const SUPPORTED_FORM_FIELD_TYPES = [ FieldMetadataType.UUID, FieldMetadataType.ARRAY, FieldMetadataType.RELATION, + FieldMetadataType.MORPH_RELATION, FieldMetadataType.RICH_TEXT, ]; @@ -37,8 +38,10 @@ export const shouldDisplayFormField = ({ } const isIdField = fieldMetadataItem.name === 'id'; + const isNotSupportedRelation = - fieldMetadataItem.type === FieldMetadataType.RELATION && + (fieldMetadataItem.type === FieldMetadataType.RELATION || + fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION) && fieldMetadataItem.settings?.['relationType'] !== 'MANY_TO_ONE'; switch (actionType) { diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablePicker.tsx b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablePicker.tsx index 001511e3f6..6d9abface4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablePicker.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablePicker.tsx @@ -63,6 +63,7 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({ onVariableSelect, shouldDisplayRecordObjects = false, shouldDisplayRecordFields = true, + objectNameSingularsToSelect, }) => { const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${instanceId}`; const isDropdownOpen = useAtomComponentStateValue( @@ -82,6 +83,7 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({ disabled={disabled} shouldDisplayRecordObjects={shouldDisplayRecordObjects} shouldDisplayRecordFields={shouldDisplayRecordFields} + objectNameSingularsToSelect={objectNameSingularsToSelect} /> ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdown.tsx b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdown.tsx index 594cdc9c6d..85dc736df7 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdown.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdown.tsx @@ -39,6 +39,7 @@ export const WorkflowVariablesDropdown = ({ onVariableSelect, shouldDisplayRecordFields, shouldDisplayRecordObjects, + objectNameSingularsToSelect, }: { clickableComponent?: React.ReactNode; disabled?: boolean; @@ -47,6 +48,7 @@ export const WorkflowVariablesDropdown = ({ onVariableSelect: (variableName: string) => void; shouldDisplayRecordFields: boolean; shouldDisplayRecordObjects: boolean; + objectNameSingularsToSelect?: string[]; }) => { const { theme } = useContext(ThemeContext); const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${instanceId}`; @@ -119,6 +121,7 @@ export const WorkflowVariablesDropdown = ({ onSelect={handleSubItemSelect} onBack={handleBack} shouldDisplayRecordObjects={shouldDisplayRecordObjects} + objectNameSingularsToSelect={objectNameSingularsToSelect} /> ) } diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems.tsx b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems.tsx index a4744fb84e..a50200b965 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems.tsx @@ -29,6 +29,7 @@ type WorkflowVariablesDropdownStepItemsProps = { onSelect: (value: string) => void; onBack: () => void; shouldDisplayRecordObjects: boolean; + objectNameSingularsToSelect?: string[]; }; export const WorkflowVariablesDropdownStepItems = ({ @@ -36,6 +37,7 @@ export const WorkflowVariablesDropdownStepItems = ({ onSelect, onBack, shouldDisplayRecordObjects, + objectNameSingularsToSelect, }: WorkflowVariablesDropdownStepItemsProps) => { const { t } = useLingui(); const { getIcon } = useIcons(); @@ -97,8 +99,18 @@ export const WorkflowVariablesDropdownStepItems = ({ : true; const objectLabel = displayedSubStepObjectMetadata?.labelSingular; + + const isSubStepObjectSelectable = + !isDefined(objectNameSingularsToSelect) || + (isDefined(displayedSubStepObjectMetadata) && + objectNameSingularsToSelect.includes( + displayedSubStepObjectMetadata.nameSingular, + )); + const shouldDisplaySubStepObject = - shouldDisplayRecordObjects && isObjectFoundThroughSearch; + shouldDisplayRecordObjects && + isObjectFoundThroughSearch && + isSubStepObjectSelectable; const displayedSubStepObjectIconProps = isDefined( displayedSubStepObjectMetadata, diff --git a/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts b/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts index d654f3f130..78214b6224 100644 --- a/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts @@ -15,9 +15,14 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; @CoreResolver() -@UseFilters(SearchApiExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter) +@UseFilters( + SearchApiExceptionFilter, + PermissionsGraphqlApiExceptionFilter, + PreventNestToAutoLogGraphqlErrorsFilter, +) @UsePipes(ResolverValidationPipe) @UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) export class SearchResolver { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util.ts index 4fa1c125c4..aa9b186f0e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util.ts @@ -2,11 +2,44 @@ import { isObject, isString } from '@sniptt/guards'; import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util'; import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util'; +import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util'; import { type ObjectMetadataInfo } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +type MorphRelationTargetJoinColumn = { + joinColumnName: string; + targetObjectMetadataId: string; +}; + +type ExtractedMorphValue = { + targetObjectMetadataId: string; + id: string; +}; + +const extractMorphValue = (value: unknown): ExtractedMorphValue | null => { + if (!isObject(value)) { + return null; + } + + const record = value as Record; + + if ( + isString(record.targetObjectMetadataId) && + isString(record.id) && + isDefined(record.id) + ) { + return { + targetObjectMetadataId: record.targetObjectMetadataId, + id: record.id, + }; + } + + return null; +}; + const extractLegacyRelationId = (value: unknown): string | undefined => { if (!isObject(value)) { return undefined; @@ -21,7 +54,113 @@ const extractLegacyRelationId = (value: unknown): string | undefined => { return record.id; }; -export const formatWorkflowRecordRelationFields = ( +const formatWorkflowRecordMorphRelationFields = ( + record: Record, + objectMetadataInfo: ObjectMetadataInfo, +): { + formattedRecord: Record; + joinColumnNamesByMorphFieldName: Record; +} => { + const { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps } = + objectMetadataInfo; + + const objectFields = getFlatFieldsFromFlatObjectMetadata( + flatObjectMetadata, + flatFieldMetadataMaps, + ); + + const targetJoinColumnsByMorphFieldName = new Map< + string, + MorphRelationTargetJoinColumn[] + >(); + + for (const field of objectFields) { + if ( + !isFlatFieldMetadataOfType(field, FieldMetadataType.MORPH_RELATION) || + field.settings.relationType !== RelationType.MANY_TO_ONE + ) { + continue; + } + + const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: field.relationTargetObjectMetadataId, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(targetObjectMetadata)) { + continue; + } + + const morphFieldName = getMorphNameFromMorphFieldMetadataName({ + morphRelationFlatFieldMetadata: field, + nameSingular: targetObjectMetadata.nameSingular, + namePlural: targetObjectMetadata.namePlural, + }); + + const joinColumnName = computeMorphOrRelationFieldJoinColumnName({ + name: field.name, + }); + + const existing = + targetJoinColumnsByMorphFieldName.get(morphFieldName) ?? []; + + targetJoinColumnsByMorphFieldName.set(morphFieldName, [ + ...existing, + { + joinColumnName, + targetObjectMetadataId: field.relationTargetObjectMetadataId, + }, + ]); + } + + const formattedRecord: Record = {}; + const joinColumnNamesByMorphFieldName: Record = {}; + + for (const [key, value] of Object.entries(record)) { + const targetJoinColumns = targetJoinColumnsByMorphFieldName.get(key); + + if (!isDefined(targetJoinColumns)) { + formattedRecord[key] = value; + continue; + } + + const registerJoinColumns = () => { + joinColumnNamesByMorphFieldName[key] = targetJoinColumns.map( + (targetJoinColumn) => targetJoinColumn.joinColumnName, + ); + + for (const { joinColumnName } of targetJoinColumns) { + formattedRecord[joinColumnName] = null; + } + }; + + if (value === null) { + registerJoinColumns(); + continue; + } + + const morphValue = extractMorphValue(value); + + const matchingTargetJoinColumn = isDefined(morphValue) + ? targetJoinColumns.find( + (targetJoinColumn) => + targetJoinColumn.targetObjectMetadataId === + morphValue.targetObjectMetadataId, + ) + : undefined; + + if (!isDefined(morphValue) || !isDefined(matchingTargetJoinColumn)) { + continue; + } + + registerJoinColumns(); + formattedRecord[matchingTargetJoinColumn.joinColumnName] = morphValue.id; + } + + return { formattedRecord, joinColumnNamesByMorphFieldName }; +}; + +const formatWorkflowRecordSimpleRelationFields = ( record: Record, objectMetadataInfo: ObjectMetadataInfo, ): Record => { @@ -70,3 +209,23 @@ export const formatWorkflowRecordRelationFields = ( return formattedRecord; }; + +export const formatWorkflowRecordRelationFields = ( + record: Record, + objectMetadataInfo: ObjectMetadataInfo, +): { + formattedRecord: Record; + joinColumnNamesByMorphFieldName: Record; +} => { + const { + formattedRecord: recordWithMorphRelations, + joinColumnNamesByMorphFieldName, + } = formatWorkflowRecordMorphRelationFields(record, objectMetadataInfo); + + const formattedRecord = formatWorkflowRecordSimpleRelationFields( + recordWithMorphRelations, + objectMetadataInfo, + ); + + return { formattedRecord, joinColumnNamesByMorphFieldName }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts index 6c0290be2f..9b7a8354e8 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts @@ -59,10 +59,11 @@ export class CreateRecordWorkflowAction implements WorkflowAction { context, ) as WorkflowCreateRecordActionInput; - const formattedObjectRecord = formatWorkflowRecordRelationFields( - workflowActionInput.objectRecord, - objectMetadataInfo, - ); + const { formattedRecord: formattedObjectRecord } = + formatWorkflowRecordRelationFields( + workflowActionInput.objectRecord, + objectMetadataInfo, + ); const filteredObjectRecord = filterValidFieldsInRecord( formattedObjectRecord, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts index 5f9f93a9ad..3559f13b3c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts @@ -82,7 +82,10 @@ export class UpdateRecordWorkflowAction implements WorkflowAction { ); } - const formattedObjectRecord = formatWorkflowRecordRelationFields( + const { + formattedRecord: formattedObjectRecord, + joinColumnNamesByMorphFieldName, + } = formatWorkflowRecordRelationFields( workflowActionInput.objectRecord, objectMetadataInfo, ); @@ -93,7 +96,11 @@ export class UpdateRecordWorkflowAction implements WorkflowAction { objectMetadataInfo.flatFieldMetadataMaps, ); - const filteredFieldsToUpdate = workflowActionInput.fieldsToUpdate?.filter( + const expandedFieldsToUpdate = workflowActionInput.fieldsToUpdate?.flatMap( + (fieldName) => joinColumnNamesByMorphFieldName[fieldName] ?? [fieldName], + ); + + const filteredFieldsToUpdate = expandedFieldsToUpdate?.filter( (fieldName) => fieldName in filteredObjectRecord, ); 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 index 173bab49d3..dba22787fe 100644 --- 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 @@ -77,10 +77,11 @@ export class UpsertRecordWorkflowAction implements WorkflowAction { ); } - const formattedObjectRecord = formatWorkflowRecordRelationFields( - workflowActionInput.objectRecord, - objectMetadataInfo, - ); + const { formattedRecord: formattedObjectRecord } = + formatWorkflowRecordRelationFields( + workflowActionInput.objectRecord, + objectMetadataInfo, + ); const filteredObjectRecord = filterValidFieldsInRecord( formattedObjectRecord,