From 41d1b478b0e72b5aefd9facf1ae901ef13a8caf9 Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Wed, 24 Jun 2026 12:06:37 +0200 Subject: [PATCH] Fix Opportunity email timeline relation traversal (#22064) ## Summary - Stop the related-person path walker from traversing system objects while deriving timeline people. - Keep direct `person` terminal paths valid so CRM relations still resolve. - Add a regression test covering the bad Opportunity owner -> workspace member -> message participant path. ## Root Cause PR #21684 introduced generic relation traversal for email and calendar timelines. That traversal walks relation paths from the current record to `person`, then the Emails tab loads message threads for those derived people. For Opportunities, the traversal was too broad because it could enter internal/system objects. In particular, it could follow: `opportunity.owner -> workspaceMember.messageParticipants -> messageParticipant.person` That path does not describe people related to the Opportunity. It describes people who appeared in messages involving the Opportunity owner. As a result, an Opportunity owned by Josh could show threads from Josh's broader mailbox activity, which matches the customer report: recently communicated people appeared in the Opportunity Emails tab even though they were not specifically related to that Opportunity. ## Behavior Before On an Opportunity record, the Emails tab could include message threads for: - the Opportunity point of contact; - people related through the Opportunity company; - people reached through internal/system relations, including the owner workspace member's message participants. The last category was the regression. It made the Opportunity Emails tab look like a broad inbox for the owner instead of a timeline for people related to the CRM record. ## Behavior After The traversal still allows valid CRM person paths, including: `opportunity.pointOfContact -> person` and non-system CRM paths such as: `opportunity.company -> company.people -> person` But it now stops before traversing system objects such as `workspaceMember` and `messageParticipant`. This blocks the bad owner-mailbox expansion path: `opportunity.owner -> workspaceMember.messageParticipants -> messageParticipant.person` Email sync is unchanged. This only changes which synced emails are displayed on a record timeline. ## Video https://github.com/user-attachments/assets/26de4cee-06d9-4f42-b91e-32e60a260b5b ## Validation - `yarn nx jest twenty-server src/engine/core-modules/related-person-ids/utils/__tests__/find-relation-paths-to-person.util.spec.ts --runInBand` - Focused `oxlint` and `oxfmt` on the touched files. - GitHub `server-lint-typecheck` passes on the updated branch. - Browser verification on local Apple seed workspace: fixed relation set renders `Inbox 280`; the excluded owner-derived path would have resolved `300` threads. --- ...find-relation-paths-to-person.util.spec.ts | 63 ++++++++++++++++++- .../find-relation-paths-to-person.util.ts | 16 ++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/related-person-ids/utils/__tests__/find-relation-paths-to-person.util.spec.ts b/packages/twenty-server/src/engine/core-modules/related-person-ids/utils/__tests__/find-relation-paths-to-person.util.spec.ts index 04717ee5b6..d07eac2cc9 100644 --- a/packages/twenty-server/src/engine/core-modules/related-person-ids/utils/__tests__/find-relation-paths-to-person.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/related-person-ids/utils/__tests__/find-relation-paths-to-person.util.spec.ts @@ -18,7 +18,11 @@ type RelationSpec = { inverseFieldName: string; }; -const buildGraphFixtures = (graph: Record) => { +const buildGraphFixtures = ( + graph: Record, + options: { systemObjectNames?: string[] } = {}, +) => { + const systemObjectNames = options.systemObjectNames ?? []; const fieldId = (objectNameSingular: string, fieldName: string) => `${objectNameSingular}.${fieldName}`; @@ -82,6 +86,7 @@ const buildGraphFixtures = (graph: Record) => { targetObjectMetadata: { id: spec.targetObjectNameSingular, nameSingular: spec.targetObjectNameSingular, + isSystem: systemObjectNames.includes(spec.targetObjectNameSingular), }, sourceFieldMetadata: { name: spec.fieldName }, targetFieldMetadata: { name: spec.inverseFieldName }, @@ -261,6 +266,62 @@ describe('findRelationPathsToPerson', () => { ]); }); + it('does not traverse system objects while looking for related people', () => { + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + buildGraphFixtures( + { + opportunity: [ + { + fieldName: 'pointOfContact', + relationType: RelationType.MANY_TO_ONE, + targetObjectNameSingular: 'person', + inverseFieldName: 'pointOfContactForOpportunities', + }, + { + fieldName: 'owner', + relationType: RelationType.MANY_TO_ONE, + targetObjectNameSingular: 'workspaceMember', + inverseFieldName: 'ownedOpportunities', + }, + ], + workspaceMember: [ + { + fieldName: 'messageParticipants', + relationType: RelationType.ONE_TO_MANY, + targetObjectNameSingular: 'messageParticipant', + inverseFieldName: 'workspaceMember', + }, + ], + messageParticipant: [ + { + fieldName: 'person', + relationType: RelationType.MANY_TO_ONE, + targetObjectNameSingular: 'person', + inverseFieldName: 'messageParticipants', + }, + ], + person: [], + }, + { systemObjectNames: ['workspaceMember', 'messageParticipant'] }, + ); + + expect( + findRelationPathsToPerson({ + rootObjectNameSingular: 'opportunity', + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }), + ).toEqual([ + [ + { + direction: RelationType.MANY_TO_ONE, + queryObjectNameSingular: 'opportunity', + joinColumnName: 'pointOfContactId', + }, + ], + ]); + }); + it('returns no path when person is unreachable, terminating on relation cycles', () => { const { flatObjectMetadataMaps, flatFieldMetadataMaps } = buildGraphFixtures({ diff --git a/packages/twenty-server/src/engine/core-modules/related-person-ids/utils/find-relation-paths-to-person.util.ts b/packages/twenty-server/src/engine/core-modules/related-person-ids/utils/find-relation-paths-to-person.util.ts index 0cc45716c2..f948a00618 100644 --- a/packages/twenty-server/src/engine/core-modules/related-person-ids/utils/find-relation-paths-to-person.util.ts +++ b/packages/twenty-server/src/engine/core-modules/related-person-ids/utils/find-relation-paths-to-person.util.ts @@ -15,6 +15,9 @@ import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object const PERSON_OBJECT_NAME_SINGULAR = 'person'; const DEFAULT_MAX_RELATION_DEPTH_TO_PERSON = 3; +const isSystemObjectMetadata = (objectMetadata: { isSystem?: boolean }) => + objectMetadata.isSystem === true; + export type RelationHopToPerson = { direction: RelationType; queryObjectNameSingular: string; @@ -103,14 +106,21 @@ export const findRelationPathsToPerson = ({ }, ]; - const targetObjectId = relation.targetObjectMetadata.id; - if ( relation.targetObjectMetadata.nameSingular === PERSON_OBJECT_NAME_SINGULAR ) { pathsToPerson.push(nextPath); - } else if (!visitedObjectIds.has(targetObjectId)) { + continue; + } + + if (isSystemObjectMetadata(relation.targetObjectMetadata)) { + continue; + } + + const targetObjectId = relation.targetObjectMetadata.id; + + if (!visitedObjectIds.has(targetObjectId)) { objectIdsReachedThisDepth.add(targetObjectId); nextFrontier.push({ objectId: targetObjectId, path: nextPath }); }