From 44ed0d5498de78df737510bdcdd0e13483be0cb7 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Mon, 27 Jul 2026 11:35:06 +0200 Subject: [PATCH] Fix phantom targetId field in workflow triggers for morph relations (#23290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem In a workflow **record-change trigger** on `noteTarget`, the output variables offered a `targetId` field that doesn't exist. A morph relation reaches the frontend as a single field named `target` (the per-target morph fields are grouped by `morphId`), so the output-schema generators synthesized its foreign-key column as `` `${field.name}Id` `` → `targetId`. But `noteTarget` has no `targetId` column; its FKs are one per target type: `targetCompanyId`, `targetPersonId`, `targetOpportunityId`, etc. The phantom `targetId` never matched anything in the event payload. ## Fix New helper `getRelationIdFieldNames` returns the actual FK id column(s) for a relation field: - normal relation → `[`${name}Id`]` - morph relation → one column per `morphRelations` target, via the existing `computeMorphRelationGqlFieldJoinColumnName` (`targetCompanyId`, `targetPersonId`, ...). Used by the two output-schema generators: - `generateRecordEventOutputSchema` — the record-change trigger output variables (the reported symptom). - `generateRecordOutputSchema` — record output for form/find/update-record output schemas. Scoped strictly to the output schema; no workflow component changes. ## Testing - Unit tests updated to assert per-target columns instead of the phantom `targetId` (both generators). 28 passing. --- .../utils/getRelationIdFieldNames.ts | 25 +++++++++++++ .../utils/mapObjectMetadataToGraphQLQuery.ts | 37 ++++--------------- .../utils/shouldFieldBeQueried.ts | 30 ++------------- .../generateRecordEventOutputSchema.test.ts | 30 +++++++++++++-- .../generateRecordOutputSchema.test.ts | 26 +++++++++++-- .../generateRecordEventOutputSchema.ts | 26 +++++++------ .../generate/generateRecordOutputSchema.ts | 26 +++++++------ 7 files changed, 113 insertions(+), 87 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getRelationIdFieldNames.ts diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getRelationIdFieldNames.ts b/packages/twenty-front/src/modules/object-metadata/utils/getRelationIdFieldNames.ts new file mode 100644 index 0000000000..ed02174331 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getRelationIdFieldNames.ts @@ -0,0 +1,25 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { + computeMorphRelationGqlFieldJoinColumnName, + computeRelationGqlFieldJoinColumnName, +} from 'twenty-shared/utils'; + +export const getRelationIdFieldNames = ( + field: Pick, +): string[] => { + if (field.type === FieldMetadataType.MORPH_RELATION) { + return (field.morphRelations ?? []).map((morphRelation) => + computeMorphRelationGqlFieldJoinColumnName({ + fieldName: field.name, + relationType: morphRelation.type, + targetObjectMetadataNameSingular: + morphRelation.targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: + morphRelation.targetObjectMetadata.namePlural, + }), + ); + } + + return [computeRelationGqlFieldJoinColumnName({ name: field.name })]; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts index dcf37acd81..5c62566ba5 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts @@ -1,5 +1,6 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; +import { getRelationIdFieldNames } from '@/object-metadata/utils/getRelationIdFieldNames'; import { mapFieldMetadataToGraphQLQuery } from '@/object-metadata/utils/mapFieldMetadataToGraphQLQuery'; import { shouldFieldBeQueried } from '@/object-metadata/utils/shouldFieldBeQueried'; import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields'; @@ -10,9 +11,7 @@ import { type ObjectPermissions, } from 'twenty-shared/types'; import { - computeMorphRelationGqlFieldJoinColumnName, computeMorphRelationGqlFieldName, - computeRelationGqlFieldJoinColumnName, isDefined, } from 'twenty-shared/utils'; @@ -66,34 +65,12 @@ export const mapObjectMetadataToGraphQLQuery = ({ }); const manyToOneRelationGqlFieldWithFieldMetadata = - manyToOneRelationFields.flatMap((fieldMetadata) => { - const isMorphRelation = - fieldMetadata.type === FieldMetadataType.MORPH_RELATION; - if (!isMorphRelation) { - return { - gqlField: computeRelationGqlFieldJoinColumnName({ - name: fieldMetadata.name, - }), - fieldMetadata: fieldMetadata, - }; - } - - if (!isDefined(fieldMetadata.morphRelations)) { - return []; - } - - return fieldMetadata.morphRelations.map((morphRelation) => ({ - gqlField: computeMorphRelationGqlFieldJoinColumnName({ - fieldName: fieldMetadata.name, - relationType: morphRelation.type, - targetObjectMetadataNameSingular: - morphRelation.targetObjectMetadata.nameSingular, - targetObjectMetadataNamePlural: - morphRelation.targetObjectMetadata.namePlural, - }), - fieldMetadata: fieldMetadata, - })); - }); + manyToOneRelationFields.flatMap((fieldMetadata) => + getRelationIdFieldNames(fieldMetadata).map((gqlField) => ({ + gqlField, + fieldMetadata, + })), + ); const readableFields = objectMetadataItem.readableFields.filter( (fieldMetadata) => fieldMetadata.isActive, diff --git a/packages/twenty-front/src/modules/object-metadata/utils/shouldFieldBeQueried.ts b/packages/twenty-front/src/modules/object-metadata/utils/shouldFieldBeQueried.ts index 7b319a7332..de8e60838d 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/shouldFieldBeQueried.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/shouldFieldBeQueried.ts @@ -2,13 +2,10 @@ import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { getRelationIdFieldNames } from '@/object-metadata/utils/getRelationIdFieldNames'; import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; -import { - computeMorphRelationGqlFieldJoinColumnName, - computeRelationGqlFieldJoinColumnName, - isDefined, -} from 'twenty-shared/utils'; +import { isDefined } from 'twenty-shared/utils'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; export const shouldFieldBeQueried = ({ @@ -24,28 +21,9 @@ export const shouldFieldBeQueried = ({ objectRecord?: ObjectRecord; recordGqlFields?: RecordGqlOperationGqlRecordFields; }): any => { - const isRelationJoinColumn = - isFieldRelation(fieldMetadata) && - computeRelationGqlFieldJoinColumnName({ name: fieldMetadata.name }) === - gqlField; - - const isMorphRelationJoinColumn = - isFieldMorphRelation(fieldMetadata) && - isDefined(fieldMetadata.morphRelations) && - fieldMetadata.morphRelations.some( - (morphRelation) => - computeMorphRelationGqlFieldJoinColumnName({ - fieldName: fieldMetadata.name, - relationType: morphRelation.type, - targetObjectMetadataNameSingular: - morphRelation.targetObjectMetadata.nameSingular, - targetObjectMetadataNamePlural: - morphRelation.targetObjectMetadata.namePlural, - }) === gqlField, - ); - const isJoinColumn: boolean = - isRelationJoinColumn || isMorphRelationJoinColumn; + (isFieldRelation(fieldMetadata) || isFieldMorphRelation(fieldMetadata)) && + getRelationIdFieldNames(fieldMetadata).includes(gqlField); if ( isUndefinedOrNull(recordGqlFields) && diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/generateRecordEventOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/generateRecordEventOutputSchema.test.ts index 55da4e85b5..d782454081 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/generateRecordEventOutputSchema.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/generateRecordEventOutputSchema.test.ts @@ -261,7 +261,7 @@ describe('generateRecordEventOutputSchema', () => { }); }); - it('should convert MORPH_RELATION fields to prefixed UUID id fields when MANY_TO_ONE', () => { + it('should expand MORPH_RELATION fields into one prefixed UUID id field per target when MANY_TO_ONE', () => { const objectMetadataItem = createMockObjectMetadataItem({ fields: [ { @@ -275,6 +275,22 @@ describe('generateRecordEventOutputSchema', () => { settings: { relationType: RelationType.MANY_TO_ONE, }, + morphRelations: [ + { + type: RelationType.MANY_TO_ONE, + targetObjectMetadata: { + nameSingular: 'company', + namePlural: 'companies', + }, + }, + { + type: RelationType.MANY_TO_ONE, + targetObjectMetadata: { + nameSingular: 'person', + namePlural: 'people', + }, + }, + ], }, ] as any, }); @@ -284,8 +300,16 @@ describe('generateRecordEventOutputSchema', () => { DatabaseEventAction.CREATED, ); - expect(Object.keys(result.fields)).toContain('properties.after.targetId'); - expect(result.fields['properties.after.targetId']).toMatchObject({ + expect(Object.keys(result.fields)).toContain( + 'properties.after.targetCompanyId', + ); + expect(Object.keys(result.fields)).toContain( + 'properties.after.targetPersonId', + ); + expect(Object.keys(result.fields)).not.toContain( + 'properties.after.targetId', + ); + expect(result.fields['properties.after.targetCompanyId']).toMatchObject({ isLeaf: true, type: FieldMetadataType.UUID, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/generateRecordOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/generateRecordOutputSchema.test.ts index a6cd13357f..f053550df1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/generateRecordOutputSchema.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/__tests__/generateRecordOutputSchema.test.ts @@ -233,7 +233,7 @@ describe('generateRecordOutputSchema', () => { expect(result.object.icon).toBeUndefined(); }); - it('should convert MORPH_RELATION fields to UUID id fields when MANY_TO_ONE', () => { + it('should expand MORPH_RELATION fields into one UUID id field per target when MANY_TO_ONE', () => { const objectMetadataItem = createMockObjectMetadataItem({ fields: [ { @@ -247,6 +247,22 @@ describe('generateRecordOutputSchema', () => { settings: { relationType: RelationType.MANY_TO_ONE, }, + morphRelations: [ + { + type: RelationType.MANY_TO_ONE, + targetObjectMetadata: { + nameSingular: 'company', + namePlural: 'companies', + }, + }, + { + type: RelationType.MANY_TO_ONE, + targetObjectMetadata: { + nameSingular: 'person', + namePlural: 'people', + }, + }, + ], }, ] as any, }); @@ -254,11 +270,13 @@ describe('generateRecordOutputSchema', () => { const result = generateRecordOutputSchema(objectMetadataItem); expect(result.fields).not.toHaveProperty('target'); - expect(result.fields).toHaveProperty('targetId'); - expect(result.fields.targetId).toMatchObject({ + expect(result.fields).not.toHaveProperty('targetId'); + expect(result.fields).toHaveProperty('targetCompanyId'); + expect(result.fields).toHaveProperty('targetPersonId'); + expect(result.fields.targetCompanyId).toMatchObject({ isLeaf: true, type: FieldMetadataType.UUID, - label: 'Target Id', + label: 'Target Company Id', }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/generateRecordEventOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/generateRecordEventOutputSchema.ts index 9c17ba9b21..9102d023e1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/generateRecordEventOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/generateRecordEventOutputSchema.ts @@ -1,4 +1,5 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { getRelationIdFieldNames } from '@/object-metadata/utils/getRelationIdFieldNames'; import { type DatabaseEventTriggerOutputSchema } from '@/workflow/workflow-variables/types/DatabaseEventTriggerOutputSchema'; import { type FieldOutputSchemaV2, @@ -121,18 +122,19 @@ const generatePrefixedRecordFields = ( fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION; if (isRelationField) { - const relationIdFieldName = `${fieldMetadataItem.name}Id`; - const relationIdFieldLabel = camelToTitleCase(relationIdFieldName); - - result[`${prefix}.${relationIdFieldName}`] = { - isLeaf: true, - icon: fieldMetadataItem.icon ?? undefined, - type: FieldMetadataType.UUID, - label: relationIdFieldLabel, - value: generateFakeValue(FieldMetadataType.UUID, 'FieldMetadataType'), - fieldMetadataId: fieldMetadataItem.id, - isCompositeSubField: false, - }; + for (const relationIdFieldName of getRelationIdFieldNames( + fieldMetadataItem, + )) { + result[`${prefix}.${relationIdFieldName}`] = { + isLeaf: true, + icon: fieldMetadataItem.icon ?? undefined, + type: FieldMetadataType.UUID, + label: camelToTitleCase(relationIdFieldName), + value: generateFakeValue(FieldMetadataType.UUID, 'FieldMetadataType'), + fieldMetadataId: fieldMetadataItem.id, + isCompositeSubField: false, + }; + } } else { Object.assign( result, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/generateRecordOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/generateRecordOutputSchema.ts index aa6dc832f3..c2f3de0beb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/generateRecordOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/generate/generateRecordOutputSchema.ts @@ -1,5 +1,6 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { getRelationIdFieldNames } from '@/object-metadata/utils/getRelationIdFieldNames'; import { type FieldOutputSchemaV2, type RecordFieldLeaf, @@ -112,18 +113,19 @@ const generateRecordFields = ( fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION; if (isRelationField) { - const relationIdFieldName = `${fieldMetadataItem.name}Id`; - const relationIdFieldLabel = camelToTitleCase(relationIdFieldName); - - result[relationIdFieldName] = { - isLeaf: true, - icon: fieldMetadataItem.icon ?? undefined, - type: FieldMetadataType.UUID, - label: relationIdFieldLabel, - value: generateFakeValue(FieldMetadataType.UUID, 'FieldMetadataType'), - fieldMetadataId: fieldMetadataItem.id, - isCompositeSubField: false, - }; + for (const relationIdFieldName of getRelationIdFieldNames( + fieldMetadataItem, + )) { + result[relationIdFieldName] = { + isLeaf: true, + icon: fieldMetadataItem.icon ?? undefined, + type: FieldMetadataType.UUID, + label: camelToTitleCase(relationIdFieldName), + value: generateFakeValue(FieldMetadataType.UUID, 'FieldMetadataType'), + fieldMetadataId: fieldMetadataItem.id, + isCompositeSubField: false, + }; + } } else { result[fieldMetadataItem.name] = generateRecordField(fieldMetadataItem); }