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.
This commit is contained in:
Thomas des Francs
2026-06-24 12:06:37 +02:00
committed by GitHub
parent eb53dee3be
commit 41d1b478b0
2 changed files with 75 additions and 4 deletions
@@ -18,7 +18,11 @@ type RelationSpec = {
inverseFieldName: string;
};
const buildGraphFixtures = (graph: Record<string, RelationSpec[]>) => {
const buildGraphFixtures = (
graph: Record<string, RelationSpec[]>,
options: { systemObjectNames?: string[] } = {},
) => {
const systemObjectNames = options.systemObjectNames ?? [];
const fieldId = (objectNameSingular: string, fieldName: string) =>
`${objectNameSingular}.${fieldName}`;
@@ -82,6 +86,7 @@ const buildGraphFixtures = (graph: Record<string, RelationSpec[]>) => {
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({
@@ -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 });
}