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
@@ -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);
}