diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx index eee1c8ab75..cdd8f1661e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx @@ -10,7 +10,6 @@ import { isActivityTargetField } from '@/object-record/record-field-list/utils/c import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus'; import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRelationFromManyFieldDisplay'; -import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay'; import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction'; import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig'; import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig'; @@ -147,7 +146,7 @@ export const RelationFromManyFieldDisplay = () => { .filter(isDefined); if (fieldValue.some(isDefined) && targetRecordsWithMetadata.length === 0) { - return ; + return null; } return ( diff --git a/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts b/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts index 2e2d29ba56..af9f8d5de7 100644 --- a/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts +++ b/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { type FindOptionsRelations, type ObjectLiteral } from 'typeorm'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; @@ -173,7 +174,7 @@ export class ProcessNestedRelationsV2Helper { targetObjectNameSingular, ); - const columnsToSelect = buildColumnsToSelect({ + const columnsToSelect: Record = buildColumnsToSelect({ select: selectedFields, relations: nestedRelations, flatObjectMetadata: targetObjectMetadata, @@ -181,17 +182,22 @@ export class ProcessNestedRelationsV2Helper { flatFieldMetadataMaps, }); + if (relationType === RelationType.MANY_TO_ONE) { + columnsToSelect.deletedAt = true; + targetObjectQueryBuilder = targetObjectQueryBuilder.withDeleted(); + } + targetObjectQueryBuilder = targetObjectQueryBuilder.setFindOptions({ select: columnsToSelect, }); + const joinColumnName = + sourceFieldMetadata.settings.joinColumnName ?? `${sourceFieldName}Id`; + const relationIds = this.getUniqueIds({ records: parentObjectRecords, idField: - relationType === RelationType.ONE_TO_MANY - ? 'id' - : (sourceFieldMetadata.settings.joinColumnName ?? - `${sourceFieldName}Id`), + relationType === RelationType.ONE_TO_MANY ? 'id' : joinColumnName, }); const fieldMetadataTargetRelationColumnName = @@ -227,7 +233,9 @@ export class ProcessNestedRelationsV2Helper { relationType === RelationType.ONE_TO_MANY ? `${fieldMetadataTargetRelationColumnName}` : 'id', + joinColumnName, relationType, + selectedFields, }); if (Object.keys(nestedRelations).length > 0) { @@ -398,7 +406,9 @@ export class ProcessNestedRelationsV2Helper { relationAggregatedFieldsResult, sourceFieldName, joinField, + joinColumnName, relationType, + selectedFields, }: { parentRecords: ObjectRecord[]; // oxlint-disable-next-line @typescripttypescript/no-explicit-any @@ -409,7 +419,9 @@ export class ProcessNestedRelationsV2Helper { relationAggregatedFieldsResult: Record; sourceFieldName: string; joinField: string; + joinColumnName: string; relationType: RelationType; + selectedFields: Record; }): void { parentRecords.forEach((item) => { if (relationType === RelationType.ONE_TO_MANY) { @@ -417,10 +429,24 @@ export class ProcessNestedRelationsV2Helper { (rel) => rel[joinField] === item.id, ); } else { - item[sourceFieldName] = - relationResults.find( - (rel) => rel.id === item[`${sourceFieldName}Id`], - ) ?? null; + const matchedRelation = relationResults.find( + (rel) => rel.id === item[joinColumnName], + ); + + if (isDefined(matchedRelation?.deletedAt)) { + item[sourceFieldName] = null; + item[joinColumnName] = null; + } else if (isDefined(matchedRelation)) { + if (selectedFields?.deletedAt !== true) { + const { deletedAt: _, ...rest } = matchedRelation; + + item[sourceFieldName] = rest; + } else { + item[sourceFieldName] = matchedRelation; + } + } else { + item[sourceFieldName] = null; + } } }); diff --git a/packages/twenty-server/test/integration/graphql/suites/soft-deleted-relation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/soft-deleted-relation.integration-spec.ts new file mode 100644 index 0000000000..7e6a426449 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/soft-deleted-relation.integration-spec.ts @@ -0,0 +1,135 @@ +import { randomUUID } from 'crypto'; + +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util'; +import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; +import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { restoreOneOperationFactory } from 'test/integration/graphql/utils/restore-one-operation-factory.util'; + +const PERSON_WITH_COMPANY_GQL_FIELDS = ` + id + companyId + company { + id + name + } +`; + +describe('soft-deleted relation', () => { + const companyId = randomUUID(); + const personId = randomUUID(); + + beforeAll(async () => { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id name', + data: { id: companyId, name: 'SoftDeleteTestCompany' }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS, + data: { + id: personId, + companyId, + name: { firstName: 'SoftDeleteTest' }, + }, + }), + ); + }); + + afterAll(async () => { + // Ensure records are not soft-deleted before destroying + await makeGraphqlAPIRequest( + restoreOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id', + recordId: companyId, + }), + ); + + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: personId, + }), + ); + + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id', + recordId: companyId, + }), + ); + }); + + it('should return company relation when company is live', async () => { + const response = await makeGraphqlAPIRequest( + findOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS, + filter: { id: { eq: personId } }, + }), + ); + + const person = response.body.data.person; + + expect(person.companyId).toBe(companyId); + expect(person.company).toBeDefined(); + expect(person.company.id).toBe(companyId); + expect(person.company.name).toBe('SoftDeleteTestCompany'); + }); + + it('should nullify companyId when company is soft-deleted', async () => { + await makeGraphqlAPIRequest( + deleteOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id deletedAt', + recordId: companyId, + }), + ); + + const response = await makeGraphqlAPIRequest( + findOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS, + filter: { id: { eq: personId } }, + }), + ); + + const person = response.body.data.person; + + expect(person.companyId).toBeNull(); + expect(person.company).toBeNull(); + }); + + it('should restore company relation when company is restored', async () => { + await makeGraphqlAPIRequest( + restoreOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id deletedAt', + recordId: companyId, + }), + ); + + const response = await makeGraphqlAPIRequest( + findOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS, + filter: { id: { eq: personId } }, + }), + ); + + const person = response.body.data.person; + + expect(person.companyId).toBe(companyId); + expect(person.company).toBeDefined(); + expect(person.company.id).toBe(companyId); + }); +});