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:
@@ -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 })];
|
||||
};
|
||||
+7
-30
@@ -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) &&
|
||||
|
||||
+27
-3
@@ -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,
|
||||
});
|
||||
|
||||
+22
-4
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+14
-12
@@ -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,
|
||||
|
||||
+14
-12
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user