From 7c4302d02afebc6ac9a868bb4fa43c3cab355374 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 5 May 2026 11:32:16 +0200 Subject: [PATCH] fix: show empty cell instead of 'Not shared' for soft-deleted related records (#20260) ## Summary Fixes #20076 (supersedes #20250) When a related record is soft-deleted, the frontend displays "Not shared" (lock icon) because it sees a populated FK but a null relation object. This is misleading -- the record was deleted, not permission-restricted. **Backend fix** (`process-nested-relations-v2.helper.ts`): - For MANY_TO_ONE relations, widen the relation query with `.withDeleted()` and include `deletedAt` in the select - In `assignRelationResults`, if the matched record has `deletedAt` set, nullify both the FK and the relation object in the API response - Records filtered by RLS are still not returned (even with `withDeleted()`), so they correctly continue to show "Not shared" - Strip `deletedAt` from relation results before returning to the client **Frontend fix** (`RelationFromManyFieldDisplay.tsx`): - For ONE_TO_MANY junction relations, return `null` instead of `` when junction records exist but target records are unavailable ### Three cases now handled correctly: | Scenario | FK in response | Relation object | Frontend display | |---|---|---|---| | **Live record** | `"abc"` | `{ id: "abc", ... }` | Record chip | | **Soft-deleted record** | `null` | `null` | Empty cell | | **RLS-hidden record** | `"abc"` | `null` | "Not shared" | ## Test plan - [ ] Create a record with a MANY_TO_ONE relation (e.g., a person linked to a company) - [ ] Soft-delete the related record (the company) - [ ] Verify the relation field shows an empty cell, not "Not shared" - [ ] Restore the related record and verify the relation reappears - [ ] Verify that RLS-hidden relations still show "Not shared" Made with [Cursor](https://cursor.com) Co-authored-by: Cursor --- .../RelationFromManyFieldDisplay.tsx | 3 +- .../process-nested-relations-v2.helper.ts | 44 ++++-- .../soft-deleted-relation.integration-spec.ts | 135 ++++++++++++++++++ 3 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 packages/twenty-server/test/integration/graphql/suites/soft-deleted-relation.integration-spec.ts 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); + }); +});