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.

<p align="center">
<img width="854" height="480" alt="image"
src="https://github.com/user-attachments/assets/ed50a623-68d4-4266-baf1-e94a657c3fd4"
/>
</p>

If the users try to send without the "Id" suffix, they get an error
explaining what to do.

<p align="center">
<img width="854" height="480" alt="image"
src="https://github.com/user-attachments/assets/8bab399b-7a04-4b6a-86b5-6f0e0b1ecd5d"
/>
</p>

Additionally, the documentation itself contains the correct property
names.

<p align="center">
<img width="854" height="480" alt="image"
src="https://github.com/user-attachments/assets/83e51cd6-8ef7-4a4d-8696-ab37cc4a9dd6"
/>
</p>

Finally, the filters also enforce this "Id" suffix convention in the GET
request.

<p align="center">
<img width="854" height="480" alt="image"
src="https://github.com/user-attachments/assets/168a2f09-1242-40fa-bd84-1f7d9c60357c"
/>
</p>

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).
This commit is contained in:
Abdullah.
2026-03-06 18:21:19 +05:00
committed by GitHub
parent 1f1da901ea
commit 9f9a6a45dd
17 changed files with 238 additions and 70 deletions
@@ -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<string, unknown>;
if (Object.keys(record).length !== 1 || !isString(record.id)) {
return undefined;
}
return record.id;
};
export const formatWorkflowRecordRelationFields = (
record: Record<string, unknown>,
objectMetadataInfo: ObjectMetadataInfo,
): Record<string, unknown> => {
const { flatObjectMetadata, flatFieldMetadataMaps } = objectMetadataInfo;
const objectFields = getFlatFieldsFromFlatObjectMetadata(
flatObjectMetadata,
flatFieldMetadataMaps,
);
const manyToOneRelationFieldNames = new Set<string>();
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<string, unknown> = {};
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;
};
@@ -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,
);
@@ -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,
);
@@ -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,
);