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