From 9f9a6a45dd786c8dc16e758a2c0e341bd7d12dca Mon Sep 17 00:00:00 2001
From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com>
Date: Fri, 6 Mar 2026 18:21:19 +0500
Subject: [PATCH] fix: enforce the user to pass in property with id suffix for
morph relations in Rest API (#18335)
REST API allowed users to pass in targetOpportunity, targetPerson,
targetCompany etc when trying to create a noteTarget or a taskTarget.
The request went through, we got back a 201, the record was created, but
the relationship was never established since the FK was empty in the
database.
This PR enforces users to send in the property with the `Id` suffix for
consistency. So, the user sends in targetOpportunityId, targetPersonId,
targetCompanyId etc.
If the users try to send without the "Id" suffix, they get an error
explaining what to do.
Additionally, the documentation itself contains the correct property
names.
Finally, the filters also enforce this "Id" suffix convention in the GET
request.
Edit: Updated error messages after the screenshots were taken to make
them a little generic. Secondly, this PR also fixes the issue of morph
relation ids and objects not appearing in the response (when depth is
1).
---
...-metadata-config-by-field-name.constant.ts | 2 +-
...-inputs-by-field-metadata-type.constant.ts | 4 +-
.../data-arg-processor.service.spec.ts | 2 +-
.../data-arg-processor.service.ts | 32 ++++++--
.../is-relation-nested-operation.util.ts | 18 +++++
.../common-select-fields-helper.ts | 5 +-
.../get-all-selectable-fields.util.spec.ts | 4 +-
.../utils/get-all-selectable-fields.util.ts | 17 +++--
.../__tests__/selected-fields-handler.spec.ts | 7 +-
.../open-api/utils/components.utils.ts | 76 ++++++++++++-------
...eld-maps-from-flat-object-metadata.util.ts | 21 +++--
...ject-metadata-to-schema-properties.util.ts | 20 +++--
.../prefill-workflows.ts | 4 +-
...at-workflow-record-relation-fields.util.ts | 72 ++++++++++++++++++
.../create-record.workflow-action.ts | 8 +-
.../update-record.workflow-action.ts | 8 +-
.../upsert-record.workflow-action.ts | 8 +-
17 files changed, 238 insertions(+), 70 deletions(-)
create mode 100644 packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util.ts
create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util.ts
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/field-metadata-config-by-field-name.constant.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/field-metadata-config-by-field-name.constant.ts
index a07d592f90..991f7640a7 100644
--- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/field-metadata-config-by-field-name.constant.ts
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/field-metadata-config-by-field-name.constant.ts
@@ -161,7 +161,7 @@ export const fieldMetadataConfigByFieldName: Record<
isNullable: true,
},
[joinColumnNameForManyToOneMorphRelationField1]: {
- name: joinColumnNameForManyToOneMorphRelationField1,
+ name: joinColumnNameForManyToOneMorphRelationField1.replace(/Id$/, ''),
type: FieldMetadataType.MORPH_RELATION,
isNullable: true,
settings: {
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/successful-inputs-by-field-metadata-type.constant.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/successful-inputs-by-field-metadata-type.constant.ts
index 89bc9a9f71..82ceadbb7d 100644
--- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/successful-inputs-by-field-metadata-type.constant.ts
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/successful-inputs-by-field-metadata-type.constant.ts
@@ -53,7 +53,9 @@ export const successfulInputsByFieldMetadataType: {
},
{
input: { [joinColumnNameForManyToOneMorphRelationField1]: null },
- expectedOutput: { [joinColumnNameForManyToOneMorphRelationField1]: null },
+ expectedOutput: {
+ [joinColumnNameForManyToOneMorphRelationField1]: null,
+ },
},
],
[FieldMetadataType.RAW_JSON]: [
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/data-arg-processor.service.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/data-arg-processor.service.spec.ts
index 121e814194..6d7147ac85 100644
--- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/data-arg-processor.service.spec.ts
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/data-arg-processor.service.spec.ts
@@ -51,7 +51,7 @@ describe('DataArgProcessorService', () => {
byUniversalIdentifier[universalId] = {
id: fieldId,
- name: fieldName,
+ name: config.name,
type: config.type ?? FieldMetadataType.TEXT,
isNullable: config.isNullable ?? true,
objectMetadataId: 'object-id',
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service.ts
index 6fef5e71ad..e67bbeecd6 100644
--- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service.ts
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service.ts
@@ -15,6 +15,7 @@ import {
} from 'twenty-shared/utils';
import { transformActorField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-actor-field.util';
+import { isRelationNestedOperation } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util';
import { transformAddressField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-address-field.util';
import { transformArrayField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util';
import { transformCurrencyField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-currency-field.util';
@@ -57,7 +58,9 @@ import { transformRichTextV2Value } from 'src/engine/core-modules/record-transfo
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
+import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -224,13 +227,11 @@ export class DataArgProcessorService {
}
case FieldMetadataType.RELATION:
case FieldMetadataType.MORPH_RELATION: {
- const fieldMetadataRelationSettings =
- fieldMetadata.settings as FieldMetadataSettingsMapping['RELATION'];
+ const relationSettings = fieldMetadata.settings as
+ | FieldMetadataSettingsMapping['RELATION']
+ | FieldMetadataSettingsMapping['MORPH_RELATION'];
- if (
- fieldMetadataRelationSettings.relationType ===
- RelationType.ONE_TO_MANY
- ) {
+ if (relationSettings.relationType === RelationType.ONE_TO_MANY) {
throw new CommonQueryRunnerException(
`One-to-many relation ${key} field does not support write operations.`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
@@ -238,10 +239,27 @@ export class DataArgProcessorService {
);
}
- if (key === fieldMetadataRelationSettings.joinColumnName) {
+ const joinColumnName = isFlatFieldMetadataOfType(
+ fieldMetadata,
+ FieldMetadataType.MORPH_RELATION,
+ )
+ ? computeMorphOrRelationFieldJoinColumnName({
+ name: fieldMetadata.name,
+ })
+ : relationSettings.joinColumnName;
+
+ if (key === joinColumnName) {
return validateUUIDFieldOrThrow(value, key);
}
+ if (isDefined(joinColumnName) && !isRelationNestedOperation(value)) {
+ throw new CommonQueryRunnerException(
+ `Relation "${key}" requires connect or disconnect operation`,
+ CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
+ { userFriendlyMessage: STANDARD_ERROR_MESSAGE },
+ );
+ }
+
return value;
}
case FieldMetadataType.PHONES: {
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util.ts
new file mode 100644
index 0000000000..3527d40a9f
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util.ts
@@ -0,0 +1,18 @@
+import { isObject } from '@sniptt/guards';
+import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants';
+import { isDefined } from 'twenty-shared/utils';
+
+const { CONNECT, DISCONNECT } = RELATION_NESTED_QUERY_KEYWORDS;
+
+export const isRelationNestedOperation = (value: unknown): boolean => {
+ if (!isObject(value)) {
+ return false;
+ }
+
+ const obj = value as Record;
+
+ return (
+ (CONNECT in obj && isDefined(obj[CONNECT])) ||
+ (DISCONNECT in obj && isDefined(obj[DISCONNECT]))
+ );
+};
diff --git a/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts
index 985e4ed791..9d84c5be9f 100644
--- a/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts
+++ b/packages/twenty-server/src/engine/api/common/common-select-fields/common-select-fields-helper.ts
@@ -92,7 +92,10 @@ export class CommonSelectFieldsHelper {
flatEntityId: fieldId,
});
- if (!isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION)) {
+ if (
+ !isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION) &&
+ !isFlatFieldMetadataOfType(flatField, FieldMetadataType.MORPH_RELATION)
+ ) {
continue;
}
diff --git a/packages/twenty-server/src/engine/api/common/common-select-fields/utils/__tests__/get-all-selectable-fields.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/__tests__/get-all-selectable-fields.util.spec.ts
index 32c2a22da2..963bfc4416 100644
--- a/packages/twenty-server/src/engine/api/common/common-select-fields/utils/__tests__/get-all-selectable-fields.util.spec.ts
+++ b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/__tests__/get-all-selectable-fields.util.spec.ts
@@ -523,7 +523,7 @@ describe('getAllSelectableFields', () => {
});
});
- it('should include MANY_TO_ONE relations without joinColumnName as regular fields', () => {
+ it('should compute join column name for MANY_TO_ONE relations without joinColumnName in settings', () => {
const nameField = createMockField({
id: 'field-1',
name: 'name',
@@ -555,7 +555,7 @@ describe('getAllSelectableFields', () => {
expect(result).toEqual({
name: true,
- company: true, // MANY_TO_ONE without joinColumnName are included as regular fields
+ companyId: true,
});
});
});
diff --git a/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util.ts b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util.ts
index df316e9b6d..d8ab4e345a 100644
--- a/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util.ts
@@ -8,9 +8,9 @@ import {
type RestrictedFieldsPermissions,
compositeTypeDefinitions,
} from 'twenty-shared/types';
-import { isDefined } from 'twenty-shared/utils';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
+import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -80,11 +80,18 @@ export const getAllSelectableFields = ({
result[flatField.name] = compositeFields;
} else if (
- isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION) &&
- flatField.settings.relationType === RelationType.MANY_TO_ONE &&
- isDefined(flatField.settings.joinColumnName)
+ (isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION) ||
+ isFlatFieldMetadataOfType(
+ flatField,
+ FieldMetadataType.MORPH_RELATION,
+ )) &&
+ flatField.settings.relationType === RelationType.MANY_TO_ONE
) {
- result[flatField.settings.joinColumnName] = true;
+ const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
+ name: flatField.name,
+ });
+
+ result[joinColumnName] = true;
} else {
result[flatField.name] = true;
}
diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/__tests__/selected-fields-handler.spec.ts b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/__tests__/selected-fields-handler.spec.ts
index ce1d00d531..da8d52fc18 100644
--- a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/__tests__/selected-fields-handler.spec.ts
+++ b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/__tests__/selected-fields-handler.spec.ts
@@ -436,11 +436,12 @@ describe('RestToCommonSelectedFieldsHandler', () => {
expect(result).toEqual({
name: true,
+ companyId: true,
company: {
name: true,
people: {
name: true,
- company: true, // Nested relation at depth 2 shows as boolean
+ companyId: true,
},
},
});
@@ -573,12 +574,12 @@ describe('RestToCommonSelectedFieldsHandler', () => {
depth: 1,
});
- // For noteTarget, only note and task relations are included
- // but company is a MANY_TO_ONE so it's included as a boolean
expect(result).toEqual({
+ noteId: true,
note: {
title: true,
},
+ companyId: true,
company: {
name: true,
},
diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts
index 87b5dbfa83..01bbe95b60 100644
--- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts
+++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts
@@ -3,7 +3,7 @@ import {
type FieldMetadataDefaultValue,
FieldMetadataType,
} from 'twenty-shared/types';
-import { capitalize } from 'twenty-shared/utils';
+import { capitalize, isDefined } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@@ -99,43 +99,61 @@ const getSchemaComponentsRelationProperties = (
>['flatObjectMetadataMaps'],
): Properties => {
return flatFieldMetadatas.reduce((node, field) => {
- if (field.type !== FieldMetadataType.RELATION) {
+ const isRelationField =
+ isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) ||
+ isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION);
+
+ if (!isRelationField) {
return node;
}
+ if (!isDefined(field.relationTargetObjectMetadataId)) {
+ throw new Error(
+ `Relation field "${field.name}" has no relationTargetObjectMetadataId`,
+ );
+ }
+
+ const relationType = field.settings?.relationType;
+
+ if (!isDefined(relationType)) {
+ throw new Error(
+ `Relation field "${field.name}" has no relationType in settings`,
+ );
+ }
+
+ const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
+ flatEntityId: field.relationTargetObjectMetadataId,
+ flatEntityMaps: flatObjectMetadataMaps,
+ });
+
+ if (!targetObjectMetadata) {
+ throw new Error(
+ `Relation field "${field.name}" target object metadata not found for id ${field.relationTargetObjectMetadataId}`,
+ );
+ }
+
let itemProperty = {} as Property;
- if (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) {
- const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
- flatEntityId: field.relationTargetObjectMetadataId,
- flatEntityMaps: flatObjectMetadataMaps,
- });
-
- if (!targetObjectMetadata) {
- return node;
- }
-
- if (field.settings?.relationType === RelationType.MANY_TO_ONE) {
- itemProperty = {
- type: 'object',
- oneOf: [
- {
- $ref: `#/components/schemas/${capitalize(
- targetObjectMetadata.nameSingular,
- )}ForResponse`,
- },
- ],
- };
- } else if (field.settings?.relationType === RelationType.ONE_TO_MANY) {
- itemProperty = {
- type: 'array',
- items: {
+ if (relationType === RelationType.MANY_TO_ONE) {
+ itemProperty = {
+ type: 'object',
+ oneOf: [
+ {
$ref: `#/components/schemas/${capitalize(
targetObjectMetadata.nameSingular,
)}ForResponse`,
},
- };
- }
+ ],
+ };
+ } else if (relationType === RelationType.ONE_TO_MANY) {
+ itemProperty = {
+ type: 'array',
+ items: {
+ $ref: `#/components/schemas/${capitalize(
+ targetObjectMetadata.nameSingular,
+ )}ForResponse`,
+ },
+ };
}
if (field.description) {
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util.ts
index a9458cbad5..6488a8ce8e 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util.ts
@@ -1,7 +1,10 @@
+import { FieldMetadataType, RelationType } from 'twenty-shared/types';
+
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 { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
-import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
+import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export type FieldMapsForObject = {
@@ -24,14 +27,16 @@ export const buildFieldMapsFromFlatObjectMetadata = (
for (const field of objectFields) {
fieldIdByName[field.name] = field.id;
- if (isMorphOrRelationFlatFieldMetadata(field)) {
- const joinColumnName = (
- field.settings as { joinColumnName?: string } | undefined
- )?.joinColumnName;
+ if (
+ (isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION) ||
+ isFlatFieldMetadataOfType(field, FieldMetadataType.MORPH_RELATION)) &&
+ field.settings.relationType === RelationType.MANY_TO_ONE
+ ) {
+ const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
+ name: field.name,
+ });
- if (joinColumnName) {
- fieldIdByJoinColumnName[joinColumnName] = field.id;
- }
+ fieldIdByJoinColumnName[joinColumnName] = field.id;
}
}
diff --git a/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts b/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts
index 77a7cd6e8d..04ac52ec5a 100644
--- a/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts
+++ b/packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts
@@ -8,6 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
+import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@@ -102,13 +103,19 @@ export const convertObjectMetadataToSchemaProperties = ({
return node;
}
- if (
- isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
- field.settings?.relationType === RelationType.MANY_TO_ONE
- ) {
+ const isRelationManyToOne =
+ (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) ||
+ isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)) &&
+ field.settings?.relationType === RelationType.MANY_TO_ONE;
+
+ if (isRelationManyToOne) {
+ const key = computeMorphOrRelationFieldJoinColumnName({
+ name: field.name,
+ });
+
return {
...node,
- [`${field.name}Id`]: {
+ [key]: {
type: 'string',
format: 'uuid',
},
@@ -116,7 +123,8 @@ export const convertObjectMetadataToSchemaProperties = ({
}
if (
- isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
+ (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) ||
+ isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)) &&
field.settings?.relationType === RelationType.ONE_TO_MANY
) {
return node;
diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows.ts
index 5d16a3e061..3347548344 100644
--- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows.ts
@@ -270,9 +270,7 @@ export const prefillWorkflows = async (
'{{6e089bc9-aabd-435f-865f-f31c01c8f4a7.email}}',
additionalEmails: [],
},
- company: {
- id: '{{0715b6cd-7cc1-4b98-971b-00f54dfe643b.id}}',
- },
+ companyId: '{{0715b6cd-7cc1-4b98-971b-00f54dfe643b.id}}',
},
},
outputSchema: {
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
new file mode 100644
index 0000000000..03ae757176
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util.ts
@@ -0,0 +1,72 @@
+import { isObject, isString } from '@sniptt/guards';
+import { FieldMetadataType, RelationType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
+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 { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
+import { type ObjectMetadataInfo } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
+
+const extractLegacyRelationId = (value: unknown): string | undefined => {
+ if (!isObject(value)) {
+ return undefined;
+ }
+
+ const record = value as Record;
+
+ if (Object.keys(record).length !== 1 || !isString(record.id)) {
+ return undefined;
+ }
+
+ return record.id;
+};
+
+export const formatWorkflowRecordRelationFields = (
+ record: Record,
+ objectMetadataInfo: ObjectMetadataInfo,
+): Record => {
+ const { flatObjectMetadata, flatFieldMetadataMaps } = objectMetadataInfo;
+
+ const objectFields = getFlatFieldsFromFlatObjectMetadata(
+ flatObjectMetadata,
+ flatFieldMetadataMaps,
+ );
+
+ const manyToOneRelationFieldNames = new Set();
+
+ for (const field of objectFields) {
+ if (
+ (isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION) ||
+ isFlatFieldMetadataOfType(field, FieldMetadataType.MORPH_RELATION)) &&
+ field.settings.relationType === RelationType.MANY_TO_ONE
+ ) {
+ manyToOneRelationFieldNames.add(field.name);
+ }
+ }
+
+ const formattedRecord: Record = {};
+
+ for (const [key, value] of Object.entries(record)) {
+ if (!manyToOneRelationFieldNames.has(key)) {
+ formattedRecord[key] = value;
+ continue;
+ }
+
+ const legacyId = extractLegacyRelationId(value);
+
+ if (!isDefined(legacyId)) {
+ formattedRecord[key] = value;
+ continue;
+ }
+
+ const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
+ name: key,
+ });
+
+ if (!isDefined(record[joinColumnName])) {
+ formattedRecord[joinColumnName] = legacyId;
+ }
+ }
+
+ return formattedRecord;
+};
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 d55a59fc3b..6c0290be2f 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
@@ -11,6 +11,7 @@ import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { buildWorkflowActorMetadata } from 'src/modules/workflow/workflow-executor/utils/build-workflow-actor-metadata.util';
import { filterValidFieldsInRecord } from 'src/modules/workflow/workflow-executor/utils/filter-valid-fields-in-record.util';
+import { formatWorkflowRecordRelationFields } from 'src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { resolveRichTextFieldsInRecord } from 'src/modules/workflow/workflow-executor/utils/resolve-rich-text-fields-in-record.util';
import { type WorkflowCreateRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
@@ -58,8 +59,13 @@ export class CreateRecordWorkflowAction implements WorkflowAction {
context,
) as WorkflowCreateRecordActionInput;
- const filteredObjectRecord = filterValidFieldsInRecord(
+ const formattedObjectRecord = formatWorkflowRecordRelationFields(
workflowActionInput.objectRecord,
+ objectMetadataInfo,
+ );
+
+ const filteredObjectRecord = filterValidFieldsInRecord(
+ formattedObjectRecord,
objectMetadataInfo.flatObjectMetadata,
objectMetadataInfo.flatFieldMetadataMaps,
);
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 2ce5ef240c..5f9f93a9ad 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
@@ -15,6 +15,7 @@ import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { buildWorkflowActorMetadata } from 'src/modules/workflow/workflow-executor/utils/build-workflow-actor-metadata.util';
import { filterValidFieldsInRecord } from 'src/modules/workflow/workflow-executor/utils/filter-valid-fields-in-record.util';
+import { formatWorkflowRecordRelationFields } from 'src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { resolveRichTextFieldsInRecord } from 'src/modules/workflow/workflow-executor/utils/resolve-rich-text-fields-in-record.util';
import { isWorkflowUpdateRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-update-record-action.guard';
@@ -81,8 +82,13 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
);
}
- const filteredObjectRecord = filterValidFieldsInRecord(
+ const formattedObjectRecord = formatWorkflowRecordRelationFields(
workflowActionInput.objectRecord,
+ objectMetadataInfo,
+ );
+
+ const filteredObjectRecord = filterValidFieldsInRecord(
+ formattedObjectRecord,
objectMetadataInfo.flatObjectMetadata,
objectMetadataInfo.flatFieldMetadataMaps,
);
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 a28108b5c5..173bab49d3 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
@@ -14,6 +14,7 @@ import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-e
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 { filterValidFieldsInRecord } from 'src/modules/workflow/workflow-executor/utils/filter-valid-fields-in-record.util';
+import { formatWorkflowRecordRelationFields } from 'src/modules/workflow/workflow-executor/utils/format-workflow-record-relation-fields.util';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { resolveRichTextFieldsInRecord } from 'src/modules/workflow/workflow-executor/utils/resolve-rich-text-fields-in-record.util';
import { isWorkflowUpsertRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-upsert-record-action.guard';
@@ -76,8 +77,13 @@ export class UpsertRecordWorkflowAction implements WorkflowAction {
);
}
- const filteredObjectRecord = filterValidFieldsInRecord(
+ const formattedObjectRecord = formatWorkflowRecordRelationFields(
workflowActionInput.objectRecord,
+ objectMetadataInfo,
+ );
+
+ const filteredObjectRecord = filterValidFieldsInRecord(
+ formattedObjectRecord,
objectMetadataInfo.flatObjectMetadata,
objectMetadataInfo.flatFieldMetadataMaps,
);