Fix phantom targetId field in workflow triggers for morph relations (#23290)

## 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.
This commit is contained in:
Thomas Trompette
2026-07-27 11:35:06 +02:00
committed by GitHub
parent 46a3a83866
commit 44ed0d5498
7 changed files with 113 additions and 87 deletions
@@ -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<FieldMetadataItem, 'name' | 'type' | 'morphRelations'>,
): 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 })];
};
@@ -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,
@@ -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) &&
@@ -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,
});
@@ -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',
});
});
@@ -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,
@@ -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);
}