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,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))