feat(server): derive email/calendar timelines from object relations (#21684)

Simplifies our existing implementation that uses three different GraphQL
endpoints to just one `getTimelineEventsFrom{Person, Company,
Opportunity}Id` to `getTimelineCalendarEventsFromObjectRecord`


/closes https://github.com/twentyhq/twenty/issues/19676

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21684?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
neo773
2026-06-19 05:52:27 +05:30
committed by GitHub
parent 1b3d933900
commit 814b43ca41
27 changed files with 1286 additions and 484 deletions
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TimelineCalendarEventResolver } from 'src/engine/core-modules/calendar/timeline-calendar-event.resolver';
import { TimelineCalendarEventService } from 'src/engine/core-modules/calendar/timeline-calendar-event.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { RelatedPersonIdsModule } from 'src/engine/core-modules/related-person-ids/related-person-ids.module';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module';
@@ -11,6 +12,7 @@ import { UserModule } from 'src/engine/core-modules/user/user.module';
@Module({
imports: [
UserModule,
RelatedPersonIdsModule,
TypeOrmModule.forFeature([
CalendarChannelEntity,
ConnectedAccountEntity,
@@ -2,6 +2,7 @@ import { UseGuards } from '@nestjs/common';
import { Args, ArgsType, Field, Int, Query } from '@nestjs/graphql';
import { Max } from 'class-validator';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE } from 'src/engine/core-modules/calendar/constants/calendar.constants';
@@ -14,6 +15,22 @@ import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@ArgsType()
class GetTimelineCalendarEventsFromObjectRecordArgs {
@Field(() => String)
objectNameSingular: string;
@Field(() => UUIDScalarType)
recordId: string;
@Field(() => Int)
page: number;
@Field(() => Int)
@Max(TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE)
pageSize: number;
}
@ArgsType()
class GetTimelineCalendarEventsFromPersonIdArgs {
@Field(() => UUIDScalarType)
@@ -61,44 +78,68 @@ export class TimelineCalendarEventResolver {
) {}
@Query(() => TimelineCalendarEventsWithTotalDTO)
async getTimelineCalendarEventsFromObjectRecord(
@Args()
{
objectNameSingular,
recordId,
page,
pageSize,
}: GetTimelineCalendarEventsFromObjectRecordArgs,
@AuthWorkspaceMemberId() workspaceMemberId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
return this.timelineCalendarEventService.getCalendarEventsFromObjectRecord({
currentWorkspaceMemberId: workspaceMemberId,
objectNameSingular,
recordId,
workspaceId: workspace.id,
page,
pageSize,
});
}
@Query(() => TimelineCalendarEventsWithTotalDTO, {
deprecationReason: 'Use getTimelineCalendarEventsFromObjectRecord instead',
})
async getTimelineCalendarEventsFromPersonId(
@Args()
{ personId, page, pageSize }: GetTimelineCalendarEventsFromPersonIdArgs,
@AuthWorkspaceMemberId() workspaceMemberId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
const timelineCalendarEvents =
await this.timelineCalendarEventService.getCalendarEventsFromPersonIds({
currentWorkspaceMemberId: workspaceMemberId,
personIds: [personId],
workspaceId: workspace.id,
page,
pageSize,
});
return timelineCalendarEvents;
return this.timelineCalendarEventService.getCalendarEventsFromObjectRecord({
currentWorkspaceMemberId: workspaceMemberId,
objectNameSingular: CoreObjectNameSingular.Person,
recordId: personId,
workspaceId: workspace.id,
page,
pageSize,
});
}
@Query(() => TimelineCalendarEventsWithTotalDTO)
@Query(() => TimelineCalendarEventsWithTotalDTO, {
deprecationReason: 'Use getTimelineCalendarEventsFromObjectRecord instead',
})
async getTimelineCalendarEventsFromCompanyId(
@Args()
{ companyId, page, pageSize }: GetTimelineCalendarEventsFromCompanyIdArgs,
@AuthWorkspaceMemberId() workspaceMemberId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
const timelineCalendarEvents =
await this.timelineCalendarEventService.getCalendarEventsFromCompanyId({
currentWorkspaceMemberId: workspaceMemberId,
companyId,
workspaceId: workspace.id,
page,
pageSize,
});
return timelineCalendarEvents;
return this.timelineCalendarEventService.getCalendarEventsFromObjectRecord({
currentWorkspaceMemberId: workspaceMemberId,
objectNameSingular: CoreObjectNameSingular.Company,
recordId: companyId,
workspaceId: workspace.id,
page,
pageSize,
});
}
@Query(() => TimelineCalendarEventsWithTotalDTO)
@Query(() => TimelineCalendarEventsWithTotalDTO, {
deprecationReason: 'Use getTimelineCalendarEventsFromObjectRecord instead',
})
async getTimelineCalendarEventsFromOpportunityId(
@Args()
{
@@ -109,17 +150,13 @@ export class TimelineCalendarEventResolver {
@AuthWorkspaceMemberId() workspaceMemberId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
const timelineCalendarEvents =
await this.timelineCalendarEventService.getCalendarEventsFromOpportunityId(
{
currentWorkspaceMemberId: workspaceMemberId,
opportunityId,
workspaceId: workspace.id,
page,
pageSize,
},
);
return timelineCalendarEvents;
return this.timelineCalendarEventService.getCalendarEventsFromObjectRecord({
currentWorkspaceMemberId: workspaceMemberId,
objectNameSingular: CoreObjectNameSingular.Opportunity,
recordId: opportunityId,
workspaceId: workspace.id,
page,
pageSize,
});
}
}
@@ -5,6 +5,7 @@ import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/
import { CalendarChannelVisibility } from 'twenty-shared/types';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { RelatedPersonIdsService } from 'src/engine/core-modules/related-person-ids/services/related-person-ids.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@@ -96,6 +97,10 @@ describe('TimelineCalendarEventService', () => {
provide: getRepositoryToken(UserWorkspaceEntity),
useValue: mockUserWorkspaceRepository,
},
{
provide: RelatedPersonIdsService,
useValue: { getRelatedPersonIds: jest.fn().mockResolvedValue([]) },
},
],
}).compile();
@@ -8,14 +8,13 @@ import { Any, In, type Repository } from 'typeorm';
import { CalendarChannelVisibility } from 'twenty-shared/types';
import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from 'src/engine/core-modules/calendar/constants/calendar.constants';
import { type TimelineCalendarEventsWithTotalDTO } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-events-with-total.dto';
import { RelatedPersonIdsService } from 'src/engine/core-modules/related-person-ids/services/related-person-ids.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { type OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
@@ -28,6 +27,7 @@ export class TimelineCalendarEventService {
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly relatedPersonIdsService: RelatedPersonIdsService,
) {}
async getCalendarEventsFromPersonIds({
@@ -259,113 +259,40 @@ export class TimelineCalendarEventService {
);
}
async getCalendarEventsFromCompanyId({
async getCalendarEventsFromObjectRecord({
currentWorkspaceMemberId,
companyId,
objectNameSingular,
recordId,
workspaceId,
page = 1,
pageSize = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
}: {
currentWorkspaceMemberId: string;
companyId: string;
objectNameSingular: string;
recordId: string;
workspaceId: string;
page: number;
pageSize: number;
}): Promise<TimelineCalendarEventsWithTotalDTO> {
const authContext = buildSystemAuthContext(workspaceId);
const personIds = await this.relatedPersonIdsService.getRelatedPersonIds({
workspaceId,
objectNameSingular,
recordId,
});
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const personRepository =
await this.globalWorkspaceOrmManager.getRepository<PersonWorkspaceEntity>(
workspaceId,
'person',
{ shouldBypassPermissionChecks: true },
);
if (personIds.length === 0) {
return {
totalNumberOfCalendarEvents: 0,
timelineCalendarEvents: [],
};
}
const personIds = await personRepository.find({
where: {
companyId,
},
select: {
id: true,
},
});
if (personIds.length <= 0) {
return {
totalNumberOfCalendarEvents: 0,
timelineCalendarEvents: [],
};
}
const formattedPersonIds = personIds.map(({ id }) => id);
const calendarEvents = await this.getCalendarEventsFromPersonIds({
currentWorkspaceMemberId,
personIds: formattedPersonIds,
workspaceId,
page,
pageSize,
});
return calendarEvents;
},
authContext,
);
}
async getCalendarEventsFromOpportunityId({
currentWorkspaceMemberId,
opportunityId,
workspaceId,
page = 1,
pageSize = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
}: {
currentWorkspaceMemberId: string;
opportunityId: string;
workspaceId: string;
page: number;
pageSize: number;
}): Promise<TimelineCalendarEventsWithTotalDTO> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const opportunityRepository =
await this.globalWorkspaceOrmManager.getRepository<OpportunityWorkspaceEntity>(
workspaceId,
'opportunity',
{ shouldBypassPermissionChecks: true },
);
const opportunity = await opportunityRepository.findOne({
where: {
id: opportunityId,
},
select: {
companyId: true,
},
});
if (!opportunity?.companyId) {
return {
totalNumberOfCalendarEvents: 0,
timelineCalendarEvents: [],
};
}
const calendarEvents = await this.getCalendarEventsFromCompanyId({
currentWorkspaceMemberId,
companyId: opportunity.companyId,
workspaceId,
page,
pageSize,
});
return calendarEvents;
},
authContext,
);
return this.getCalendarEventsFromPersonIds({
currentWorkspaceMemberId,
personIds,
workspaceId,
page,
pageSize,
});
}
}
@@ -4,16 +4,13 @@ import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from 'src/engine/core-modules/mess
import { type TimelineThreadsWithTotalDTO } from 'src/engine/core-modules/messaging/dtos/timeline-threads-with-total.dto';
import { TimelineMessagingService } from 'src/engine/core-modules/messaging/services/timeline-messaging.service';
import { formatThreads } from 'src/engine/core-modules/messaging/utils/format-threads.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { RelatedPersonIdsService } from 'src/engine/core-modules/related-person-ids/services/related-person-ids.service';
@Injectable()
export class GetMessagesService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly timelineMessagingService: TimelineMessagingService,
private readonly relatedPersonIdsService: RelatedPersonIdsService,
) {}
async getMessagesFromPersonIds(
@@ -67,100 +64,33 @@ export class GetMessagesService {
};
}
async getMessagesFromCompanyId(
async getMessagesFromObjectRecord(
workspaceMemberId: string,
companyId: string,
objectNameSingular: string,
recordId: string,
workspaceId: string,
page = 1,
pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
): Promise<TimelineThreadsWithTotalDTO> {
const authContext = buildSystemAuthContext(workspaceId);
const personIds = await this.relatedPersonIdsService.getRelatedPersonIds({
workspaceId,
objectNameSingular,
recordId,
});
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const personRepository =
await this.globalWorkspaceOrmManager.getRepository<PersonWorkspaceEntity>(
workspaceId,
'person',
{ shouldBypassPermissionChecks: true },
);
const personIds = (
await personRepository.find({
where: {
companyId,
},
select: {
id: true,
},
})
).map((person) => person.id);
if (personIds.length === 0) {
return {
totalNumberOfThreads: 0,
timelineThreads: [],
};
}
if (personIds.length === 0) {
return {
totalNumberOfThreads: 0,
timelineThreads: [],
};
}
const messageThreads = await this.getMessagesFromPersonIds(
workspaceMemberId,
personIds,
workspaceId,
page,
pageSize,
);
return messageThreads;
},
authContext,
);
}
async getMessagesFromOpportunityId(
workspaceMemberId: string,
opportunityId: string,
workspaceId: string,
page = 1,
pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
): Promise<TimelineThreadsWithTotalDTO> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const opportunityRepository =
await this.globalWorkspaceOrmManager.getRepository<OpportunityWorkspaceEntity>(
workspaceId,
'opportunity',
{ shouldBypassPermissionChecks: true },
);
const opportunity = await opportunityRepository.findOne({
where: {
id: opportunityId,
},
select: {
companyId: true,
},
});
if (!opportunity?.companyId) {
return {
totalNumberOfThreads: 0,
timelineThreads: [],
};
}
const messageThreads = await this.getMessagesFromCompanyId(
workspaceMemberId,
opportunity.companyId,
workspaceId,
page,
pageSize,
);
return messageThreads;
},
authContext,
return this.getMessagesFromPersonIds(
workspaceMemberId,
personIds,
workspaceId,
page,
pageSize,
);
}
}
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { GetMessagesService } from 'src/engine/core-modules/messaging/services/get-messages.service';
import { TimelineMessagingService } from 'src/engine/core-modules/messaging/services/timeline-messaging.service';
import { TimelineMessagingResolver } from 'src/engine/core-modules/messaging/timeline-messaging.resolver';
import { RelatedPersonIdsModule } from 'src/engine/core-modules/related-person-ids/related-person-ids.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -20,6 +21,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
ConnectedAccountModule,
FeatureFlagModule,
PermissionsModule,
RelatedPersonIdsModule,
TypeOrmModule.forFeature([
MessageChannelEntity,
ConnectedAccountEntity,
@@ -3,6 +3,7 @@ import { Args, ArgsType, Field, Int, Mutation, Query } from '@nestjs/graphql';
import { Max } from 'class-validator';
import { PermissionFlagType } from 'twenty-shared/constants';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -21,6 +22,22 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
@ArgsType()
class GetTimelineThreadsFromObjectRecordArgs {
@Field(() => String)
objectNameSingular: string;
@Field(() => UUIDScalarType)
recordId: string;
@Field(() => Int)
page: number;
@Field(() => Int)
@Max(TIMELINE_THREADS_MAX_PAGE_SIZE)
pageSize: number;
}
@ArgsType()
class GetTimelineThreadsFromPersonIdArgs {
@Field(() => UUIDScalarType)
@@ -70,6 +87,39 @@ export class TimelineMessagingResolver {
) {}
@Query(() => TimelineThreadsWithTotalDTO)
async getTimelineThreadsFromObjectRecord(
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
@Args()
{
objectNameSingular,
recordId,
page,
pageSize,
}: GetTimelineThreadsFromObjectRecordArgs,
) {
const workspaceMember = await this.userService.loadWorkspaceMember(
user,
workspace,
);
if (!workspaceMember) {
return;
}
return this.getMessagesFromPersonIdsService.getMessagesFromObjectRecord(
workspaceMember.id,
objectNameSingular,
recordId,
workspace.id,
page,
pageSize,
);
}
@Query(() => TimelineThreadsWithTotalDTO, {
deprecationReason: 'Use getTimelineThreadsFromObjectRecord instead',
})
async getTimelineThreadsFromPersonId(
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -84,19 +134,19 @@ export class TimelineMessagingResolver {
return;
}
const timelineThreads =
await this.getMessagesFromPersonIdsService.getMessagesFromPersonIds(
workspaceMember.id,
[personId],
workspace.id,
page,
pageSize,
);
return timelineThreads;
return this.getMessagesFromPersonIdsService.getMessagesFromObjectRecord(
workspaceMember.id,
CoreObjectNameSingular.Person,
personId,
workspace.id,
page,
pageSize,
);
}
@Query(() => TimelineThreadsWithTotalDTO)
@Query(() => TimelineThreadsWithTotalDTO, {
deprecationReason: 'Use getTimelineThreadsFromObjectRecord instead',
})
async getTimelineThreadsFromCompanyId(
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -111,19 +161,19 @@ export class TimelineMessagingResolver {
return;
}
const timelineThreads =
await this.getMessagesFromPersonIdsService.getMessagesFromCompanyId(
workspaceMember.id,
companyId,
workspace.id,
page,
pageSize,
);
return timelineThreads;
return this.getMessagesFromPersonIdsService.getMessagesFromObjectRecord(
workspaceMember.id,
CoreObjectNameSingular.Company,
companyId,
workspace.id,
page,
pageSize,
);
}
@Query(() => TimelineThreadsWithTotalDTO)
@Query(() => TimelineThreadsWithTotalDTO, {
deprecationReason: 'Use getTimelineThreadsFromObjectRecord instead',
})
async getTimelineThreadsFromOpportunityId(
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -139,16 +189,14 @@ export class TimelineMessagingResolver {
return;
}
const timelineThreads =
await this.getMessagesFromPersonIdsService.getMessagesFromOpportunityId(
workspaceMember.id,
opportunityId,
workspace.id,
page,
pageSize,
);
return timelineThreads;
return this.getMessagesFromPersonIdsService.getMessagesFromObjectRecord(
workspaceMember.id,
CoreObjectNameSingular.Opportunity,
opportunityId,
workspace.id,
page,
pageSize,
);
}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { RelatedPersonIdsService } from 'src/engine/core-modules/related-person-ids/services/related-person-ids.service';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [WorkspaceCacheModule],
providers: [RelatedPersonIdsService],
exports: [RelatedPersonIdsService],
})
export class RelatedPersonIdsModule {}
@@ -0,0 +1,130 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { In, type FindOptionsSelect, type FindOptionsWhere } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import {
findRelationPathsToPerson,
type RelationPathToPerson,
} from 'src/engine/core-modules/related-person-ids/utils/find-relation-paths-to-person.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const PERSON_OBJECT_NAME_SINGULAR = 'person';
type RelationWalkRecord = { id: string } & Record<string, unknown>;
@Injectable()
export class RelatedPersonIdsService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async getRelatedPersonIds({
workspaceId,
objectNameSingular,
recordId,
}: {
workspaceId: string;
objectNameSingular: string;
recordId: string;
}): Promise<string[]> {
if (objectNameSingular === PERSON_OBJECT_NAME_SINGULAR) {
return [recordId];
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
]);
const relationPaths = findRelationPathsToPerson({
rootObjectNameSingular: objectNameSingular,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
if (relationPaths.length === 0) {
return [];
}
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const personIds = new Set<string>();
for (const relationPath of relationPaths) {
const personIdsForPath = await this.walkRelationPath({
workspaceId,
recordId,
relationPath,
});
personIdsForPath.forEach((personId) => personIds.add(personId));
}
return [...personIds];
},
buildSystemAuthContext(workspaceId),
);
}
private async walkRelationPath({
workspaceId,
recordId,
relationPath,
}: {
workspaceId: string;
recordId: string;
relationPath: RelationPathToPerson;
}): Promise<string[]> {
let currentIds = [recordId];
for (const hop of relationPath) {
if (currentIds.length === 0) {
return [];
}
const repository =
await this.globalWorkspaceOrmManager.getRepository<RelationWalkRecord>(
workspaceId,
hop.queryObjectNameSingular,
{ shouldBypassPermissionChecks: true },
);
if (hop.direction === RelationType.MANY_TO_ONE) {
const records = await repository.find({
where: { id: In(currentIds) } as FindOptionsWhere<RelationWalkRecord>,
select: {
[hop.joinColumnName]: true,
} as FindOptionsSelect<RelationWalkRecord>,
});
currentIds = [
...new Set(
records
.map((record) => record[hop.joinColumnName])
.filter(
(value): value is string =>
typeof value === 'string' && isDefined(value),
),
),
];
} else {
const records = await repository.find({
where: {
[hop.joinColumnName]: In(currentIds),
} as FindOptionsWhere<RelationWalkRecord>,
select: { id: true },
});
currentIds = [...new Set(records.map((record) => record.id))];
}
}
return currentIds;
}
}
@@ -0,0 +1,294 @@
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { resolveRelationFromFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/resolve-relation-from-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { findRelationPathsToPerson } from 'src/engine/core-modules/related-person-ids/utils/find-relation-paths-to-person.util';
jest.mock(
'src/engine/metadata-modules/flat-field-metadata/utils/resolve-relation-from-flat-field-metadata.util',
);
const resolveRelationMock = jest.mocked(resolveRelationFromFlatFieldMetadata);
type RelationSpec = {
fieldName: string;
relationType: RelationType;
targetObjectNameSingular: string;
inverseFieldName: string;
};
const buildGraphFixtures = (graph: Record<string, RelationSpec[]>) => {
const fieldId = (objectNameSingular: string, fieldName: string) =>
`${objectNameSingular}.${fieldName}`;
const flatObjectMetadataMaps = {
byUniversalIdentifier: {},
universalIdentifierById: {},
} as unknown as FlatEntityMaps<FlatObjectMetadata>;
const flatFieldMetadataMaps = {
byUniversalIdentifier: {},
universalIdentifierById: {},
} as unknown as FlatEntityMaps<FlatFieldMetadata>;
const relationSpecByFieldId = new Map<
string,
{ sourceObjectNameSingular: string; spec: RelationSpec }
>();
for (const [objectNameSingular, relationSpecs] of Object.entries(graph)) {
flatObjectMetadataMaps.byUniversalIdentifier[objectNameSingular] = {
id: objectNameSingular,
nameSingular: objectNameSingular,
fieldIds: relationSpecs.map((spec) =>
fieldId(objectNameSingular, spec.fieldName),
),
} as unknown as FlatObjectMetadata;
flatObjectMetadataMaps.universalIdentifierById[objectNameSingular] =
objectNameSingular;
for (const spec of relationSpecs) {
const id = fieldId(objectNameSingular, spec.fieldName);
flatFieldMetadataMaps.byUniversalIdentifier[id] = {
id,
type: 'RELATION',
} as unknown as FlatFieldMetadata;
flatFieldMetadataMaps.universalIdentifierById[id] = id;
relationSpecByFieldId.set(id, {
sourceObjectNameSingular: objectNameSingular,
spec,
});
}
}
resolveRelationMock.mockImplementation(({ sourceFlatFieldMetadata }) => {
const resolved = relationSpecByFieldId.get(sourceFlatFieldMetadata.id);
if (resolved === undefined) {
return null;
}
const { sourceObjectNameSingular, spec } = resolved;
return {
type: spec.relationType,
sourceObjectMetadata: {
id: sourceObjectNameSingular,
nameSingular: sourceObjectNameSingular,
},
targetObjectMetadata: {
id: spec.targetObjectNameSingular,
nameSingular: spec.targetObjectNameSingular,
},
sourceFieldMetadata: { name: spec.fieldName },
targetFieldMetadata: { name: spec.inverseFieldName },
} as ReturnType<typeof resolveRelationFromFlatFieldMetadata>;
});
return { flatObjectMetadataMaps, flatFieldMetadataMaps };
};
afterEach(() => {
resolveRelationMock.mockReset();
});
describe('findRelationPathsToPerson', () => {
it('returns the empty path for the person object itself', () => {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
buildGraphFixtures({ person: [] });
expect(
findRelationPathsToPerson({
rootObjectNameSingular: 'person',
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
).toEqual([[]]);
});
it('resolves a direct relation to person, querying person by its foreign key', () => {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
buildGraphFixtures({
company: [
{
fieldName: 'people',
relationType: RelationType.ONE_TO_MANY,
targetObjectNameSingular: 'person',
inverseFieldName: 'company',
},
],
person: [
{
fieldName: 'company',
relationType: RelationType.MANY_TO_ONE,
targetObjectNameSingular: 'company',
inverseFieldName: 'people',
},
],
});
expect(
findRelationPathsToPerson({
rootObjectNameSingular: 'company',
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
).toEqual([
[
{
direction: RelationType.ONE_TO_MANY,
queryObjectNameSingular: 'person',
joinColumnName: 'companyId',
},
],
]);
});
it('resolves person through a two-hop join object', () => {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
buildGraphFixtures({
peopleList: [
{
fieldName: 'peopleListMemberships',
relationType: RelationType.ONE_TO_MANY,
targetObjectNameSingular: 'peopleListMembership',
inverseFieldName: 'peopleList',
},
],
peopleListMembership: [
{
fieldName: 'peopleList',
relationType: RelationType.MANY_TO_ONE,
targetObjectNameSingular: 'peopleList',
inverseFieldName: 'peopleListMemberships',
},
{
fieldName: 'person',
relationType: RelationType.MANY_TO_ONE,
targetObjectNameSingular: 'person',
inverseFieldName: 'peopleListMemberships',
},
],
person: [
{
fieldName: 'peopleListMemberships',
relationType: RelationType.ONE_TO_MANY,
targetObjectNameSingular: 'peopleListMembership',
inverseFieldName: 'person',
},
],
});
expect(
findRelationPathsToPerson({
rootObjectNameSingular: 'peopleList',
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
).toEqual([
[
{
direction: RelationType.ONE_TO_MANY,
queryObjectNameSingular: 'peopleListMembership',
joinColumnName: 'peopleListId',
},
{
direction: RelationType.MANY_TO_ONE,
queryObjectNameSingular: 'peopleListMembership',
joinColumnName: 'personId',
},
],
]);
});
it('collects both a direct and a longer relation chain to person (opportunity)', () => {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
buildGraphFixtures({
opportunity: [
{
fieldName: 'pointOfContact',
relationType: RelationType.MANY_TO_ONE,
targetObjectNameSingular: 'person',
inverseFieldName: 'pointOfContactForOpportunities',
},
{
fieldName: 'company',
relationType: RelationType.MANY_TO_ONE,
targetObjectNameSingular: 'company',
inverseFieldName: 'opportunities',
},
],
company: [
{
fieldName: 'people',
relationType: RelationType.ONE_TO_MANY,
targetObjectNameSingular: 'person',
inverseFieldName: 'company',
},
],
person: [],
});
expect(
findRelationPathsToPerson({
rootObjectNameSingular: 'opportunity',
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
).toEqual([
[
{
direction: RelationType.MANY_TO_ONE,
queryObjectNameSingular: 'opportunity',
joinColumnName: 'pointOfContactId',
},
],
[
{
direction: RelationType.MANY_TO_ONE,
queryObjectNameSingular: 'opportunity',
joinColumnName: 'companyId',
},
{
direction: RelationType.ONE_TO_MANY,
queryObjectNameSingular: 'person',
joinColumnName: 'companyId',
},
],
]);
});
it('returns no path when person is unreachable, terminating on relation cycles', () => {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
buildGraphFixtures({
rocket: [
{
fieldName: 'cells',
relationType: RelationType.ONE_TO_MANY,
targetObjectNameSingular: 'rocketCell',
inverseFieldName: 'rocket',
},
],
rocketCell: [
{
fieldName: 'rocket',
relationType: RelationType.MANY_TO_ONE,
targetObjectNameSingular: 'rocket',
inverseFieldName: 'cells',
},
],
person: [],
});
expect(
findRelationPathsToPerson({
rootObjectNameSingular: 'rocket',
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
).toEqual([]);
});
});
@@ -0,0 +1,125 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { resolveRelationFromFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/resolve-relation-from-flat-field-metadata.util';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
const PERSON_OBJECT_NAME_SINGULAR = 'person';
const DEFAULT_MAX_RELATION_DEPTH_TO_PERSON = 3;
export type RelationHopToPerson = {
direction: RelationType;
queryObjectNameSingular: string;
joinColumnName: string;
};
export type RelationPathToPerson = RelationHopToPerson[];
export const findRelationPathsToPerson = ({
rootObjectNameSingular,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
maxDepth = DEFAULT_MAX_RELATION_DEPTH_TO_PERSON,
}: {
rootObjectNameSingular: string;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
maxDepth?: number;
}): RelationPathToPerson[] => {
if (rootObjectNameSingular === PERSON_OBJECT_NAME_SINGULAR) {
return [[]];
}
const { idByNameSingular } = buildObjectIdByNameMaps(flatObjectMetadataMaps);
const rootObjectId = idByNameSingular[rootObjectNameSingular];
if (!isDefined(rootObjectId)) {
return [];
}
let frontier = [{ objectId: rootObjectId, path: [] as RelationPathToPerson }];
const visitedObjectIds = new Set<string>([rootObjectId]);
const pathsToPerson: RelationPathToPerson[] = [];
for (let depth = 0; depth < maxDepth; depth++) {
const nextFrontier: typeof frontier = [];
const objectIdsReachedThisDepth = new Set<string>();
for (const { objectId, path } of frontier) {
const sourceObject = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: objectId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(sourceObject)) {
continue;
}
for (const field of getFlatFieldsFromFlatObjectMetadata(
sourceObject,
flatFieldMetadataMaps,
)) {
if (!isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION)) {
continue;
}
const relation = resolveRelationFromFlatFieldMetadata({
sourceFlatFieldMetadata: field,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
if (!isDefined(relation)) {
continue;
}
const joinColumnOwner =
relation.type === RelationType.MANY_TO_ONE
? {
object: relation.sourceObjectMetadata,
field: relation.sourceFieldMetadata,
}
: {
object: relation.targetObjectMetadata,
field: relation.targetFieldMetadata,
};
const nextPath: RelationPathToPerson = [
...path,
{
direction: relation.type,
queryObjectNameSingular: joinColumnOwner.object.nameSingular,
joinColumnName: computeMorphOrRelationFieldJoinColumnName({
name: joinColumnOwner.field.name,
}),
},
];
const targetObjectId = relation.targetObjectMetadata.id;
if (
relation.targetObjectMetadata.nameSingular ===
PERSON_OBJECT_NAME_SINGULAR
) {
pathsToPerson.push(nextPath);
} else if (!visitedObjectIds.has(targetObjectId)) {
objectIdsReachedThisDepth.add(targetObjectId);
nextFrontier.push({ objectId: targetObjectId, path: nextPath });
}
}
}
objectIdsReachedThisDepth.forEach((id) => visitedObjectIds.add(id));
frontier = nextFrontier;
}
return pathsToPerson;
};