From 19dbe28786a3b0d4e0e68fab1dad70791bc565d0 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Tue, 5 Aug 2025 03:13:09 +0530 Subject: [PATCH] feat: email and calendar events for opportunities (#13613) --- .../src/generated-metadata/graphql.ts | 16 +++++++ .../twenty-front/src/generated/graphql.ts | 16 +++++++ .../calendar/components/Calendar.tsx | 15 +++++-- ...TimelineCalendarEventsFromOpportunityId.ts | 20 +++++++++ .../emails/components/EmailThreads.tsx | 9 +++- .../getTimelineThreadsFromOpportunityId.ts | 20 +++++++++ .../activities/hooks/useCustomResolver.ts | 5 ++- .../hooks/useRecordShowContainerTabs.ts | 32 ++++++++++++++ .../timeline-calendar-event.resolver.ts | 36 ++++++++++++++++ .../timeline-calendar-event.service.ts | 43 +++++++++++++++++++ .../services/get-messages.service.ts | 38 ++++++++++++++++ .../messaging/timeline-messaging.resolver.ts | 40 +++++++++++++++++ 12 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromOpportunityId.ts create mode 100644 packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index f08e71a49de..f39f66cd73e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2109,8 +2109,10 @@ export type Query = { getServerlessFunctionSourceCode?: Maybe; getSystemHealthStatus: SystemHealth; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; + getTimelineCalendarEventsFromOpportunityId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; + getTimelineThreadsFromOpportunityId: TimelineThreadsWithTotal; getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; index: Index; indexMetadatas: IndexConnection; @@ -2269,6 +2271,13 @@ export type QueryGetTimelineCalendarEventsFromCompanyIdArgs = { }; +export type QueryGetTimelineCalendarEventsFromOpportunityIdArgs = { + opportunityId: Scalars['UUID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; +}; + + export type QueryGetTimelineCalendarEventsFromPersonIdArgs = { page: Scalars['Int']; pageSize: Scalars['Int']; @@ -2283,6 +2292,13 @@ export type QueryGetTimelineThreadsFromCompanyIdArgs = { }; +export type QueryGetTimelineThreadsFromOpportunityIdArgs = { + opportunityId: Scalars['UUID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; +}; + + export type QueryGetTimelineThreadsFromPersonIdArgs = { page: Scalars['Int']; pageSize: Scalars['Int']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 66b850b55fc..b2ca6659ebe 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -2017,8 +2017,10 @@ export type Query = { getServerlessFunctionSourceCode?: Maybe; getSystemHealthStatus: SystemHealth; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; + getTimelineCalendarEventsFromOpportunityId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; + getTimelineThreadsFromOpportunityId: TimelineThreadsWithTotal; getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; index: Index; indexMetadatas: IndexConnection; @@ -2151,6 +2153,13 @@ export type QueryGetTimelineCalendarEventsFromCompanyIdArgs = { }; +export type QueryGetTimelineCalendarEventsFromOpportunityIdArgs = { + opportunityId: Scalars['UUID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; +}; + + export type QueryGetTimelineCalendarEventsFromPersonIdArgs = { page: Scalars['Int']; pageSize: Scalars['Int']; @@ -2165,6 +2174,13 @@ export type QueryGetTimelineThreadsFromCompanyIdArgs = { }; +export type QueryGetTimelineThreadsFromOpportunityIdArgs = { + opportunityId: Scalars['UUID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; +}; + + export type QueryGetTimelineThreadsFromPersonIdArgs = { page: Scalars['Int']; pageSize: Scalars['Int']; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx index ae35cb7a54b..a2386396793 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx @@ -5,6 +5,7 @@ import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMont import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from '@/activities/calendar/constants/Calendar'; import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId'; +import { getTimelineCalendarEventsFromOpportunityId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromOpportunityId'; import { getTimelineCalendarEventsFromPersonId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromPersonId'; import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents'; import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; @@ -53,10 +54,16 @@ export const Calendar = ({ getTimelineCalendarEventsFromPersonId, 'getTimelineCalendarEventsFromPersonId', ] - : [ - getTimelineCalendarEventsFromCompanyId, - 'getTimelineCalendarEventsFromCompanyId', - ]; + : targetableObject.targetObjectNameSingular === + CoreObjectNameSingular.Company + ? [ + getTimelineCalendarEventsFromCompanyId, + 'getTimelineCalendarEventsFromCompanyId', + ] + : [ + getTimelineCalendarEventsFromOpportunityId, + 'getTimelineCalendarEventsFromOpportunityId', + ]; const { data, firstQueryLoading, isFetchingMore, fetchMoreRecords } = useCustomResolver( diff --git a/packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromOpportunityId.ts b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromOpportunityId.ts new file mode 100644 index 00000000000..84b0f698520 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromOpportunityId.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment'; + +export const getTimelineCalendarEventsFromOpportunityId = gql` + query GetTimelineCalendarEventsFromOpportunityId( + $opportunityId: UUID! + $page: Int! + $pageSize: Int! + ) { + getTimelineCalendarEventsFromOpportunityId( + opportunityId: $opportunityId + page: $page + pageSize: $pageSize + ) { + ...TimelineCalendarEventsWithTotalFragment + } + } + ${timelineCalendarEventWithTotalFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx index 03294a4992a..038077adca7 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -7,6 +7,7 @@ import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPr import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId'; import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId'; +import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId'; import { useCustomResolver } from '@/activities/hooks/useCustomResolver'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; @@ -48,7 +49,13 @@ export const EmailThreads = ({ const [query, queryName] = targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person ? [getTimelineThreadsFromPersonId, 'getTimelineThreadsFromPersonId'] - : [getTimelineThreadsFromCompanyId, 'getTimelineThreadsFromCompanyId']; + : targetableObject.targetObjectNameSingular === + CoreObjectNameSingular.Company + ? [getTimelineThreadsFromCompanyId, 'getTimelineThreadsFromCompanyId'] + : [ + getTimelineThreadsFromOpportunityId, + 'getTimelineThreadsFromOpportunityId', + ]; const { data, firstQueryLoading, isFetchingMore, fetchMoreRecords } = useCustomResolver( diff --git a/packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId.ts b/packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId.ts new file mode 100644 index 00000000000..a0856239875 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +import { timelineThreadWithTotalFragment } from '@/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment'; + +export const getTimelineThreadsFromOpportunityId = gql` + query GetTimelineThreadsFromOpportunityId( + $opportunityId: UUID! + $page: Int! + $pageSize: Int! + ) { + getTimelineThreadsFromOpportunityId( + opportunityId: $opportunityId + page: $page + pageSize: $pageSize + ) { + ...TimelineThreadsWithTotalFragment + } + } + ${timelineThreadWithTotalFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts b/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts index 273b85f7541..908165171cb 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts @@ -49,7 +49,10 @@ export const useCustomResolver = < ...(activityTargetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person ? { personId: activityTargetableObject.id } - : { companyId: activityTargetableObject.id }), + : activityTargetableObject.targetObjectNameSingular === + CoreObjectNameSingular.Opportunity + ? { opportunityId: activityTargetableObject.id } + : { companyId: activityTargetableObject.id }), page: 1, pageSize, }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts index ca4814f971c..ef911b62ec0 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts @@ -142,6 +142,38 @@ export const useRecordShowContainerTabs = ( }, }, }, + [CoreObjectNameSingular.Opportunity]: { + tabs: { + emails: { + title: 'Emails', + position: 600, + Icon: IconMail, + cards: [{ type: CardType.EmailCard }], + hide: { + ifMobile: false, + ifDesktop: false, + ifInRightDrawer: false, + ifFeaturesDisabled: [], + ifRequiredObjectsInactive: [], + ifRelationsMissing: [], + }, + }, + calendar: { + title: 'Calendar', + position: 700, + Icon: IconCalendarEvent, + cards: [{ type: CardType.CalendarCard }], + hide: { + ifMobile: false, + ifDesktop: false, + ifInRightDrawer: false, + ifFeaturesDisabled: [], + ifRequiredObjectsInactive: [], + ifRelationsMissing: [], + }, + }, + }, + }, [CoreObjectNameSingular.Workflow]: { hideSummaryAndFields: true, tabs: { diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts index 51c9ed7a488..4a4a3d09dab 100644 --- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts @@ -36,6 +36,19 @@ class GetTimelineCalendarEventsFromCompanyIdArgs { pageSize: number; } +@ArgsType() +class GetTimelineCalendarEventsFromOpportunityIdArgs { + @Field(() => UUIDScalarType) + opportunityId: string; + + @Field(() => Int) + page: number; + + @Field(() => Int) + @Max(TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE) + pageSize: number; +} + @UseGuards(WorkspaceAuthGuard) @Resolver(() => TimelineCalendarEventsWithTotal) export class TimelineCalendarEventResolver { @@ -76,4 +89,27 @@ export class TimelineCalendarEventResolver { return timelineCalendarEvents; } + + @Query(() => TimelineCalendarEventsWithTotal) + async getTimelineCalendarEventsFromOpportunityId( + @Args() + { + opportunityId, + page, + pageSize, + }: GetTimelineCalendarEventsFromOpportunityIdArgs, + @AuthWorkspaceMemberId() workspaceMemberId: string, + ) { + const timelineCalendarEvents = + await this.timelineCalendarEventService.getCalendarEventsFromOpportunityId( + { + currentWorkspaceMemberId: workspaceMemberId, + opportunityId, + page, + pageSize, + }, + ); + + return timelineCalendarEvents; + } } diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts index a3ba247ee35..42362b1709c 100644 --- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts +++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts @@ -9,6 +9,7 @@ import { TimelineCalendarEventsWithTotal } from 'src/engine/core-modules/calenda import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { CalendarChannelVisibility } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity'; import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity'; +import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; @Injectable() @@ -199,4 +200,46 @@ export class TimelineCalendarEventService { return calendarEvents; } + + async getCalendarEventsFromOpportunityId({ + currentWorkspaceMemberId, + opportunityId, + page = 1, + pageSize = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE, + }: { + currentWorkspaceMemberId: string; + opportunityId: string; + page: number; + pageSize: number; + }): Promise { + const opportunityRepository = + await this.twentyORMManager.getRepository( + 'opportunity', + ); + + 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, + page, + pageSize, + }); + + return calendarEvents; + } } diff --git a/packages/twenty-server/src/engine/core-modules/messaging/services/get-messages.service.ts b/packages/twenty-server/src/engine/core-modules/messaging/services/get-messages.service.ts index 63e5bf51cd2..fcd0ae530d4 100644 --- a/packages/twenty-server/src/engine/core-modules/messaging/services/get-messages.service.ts +++ b/packages/twenty-server/src/engine/core-modules/messaging/services/get-messages.service.ts @@ -5,6 +5,7 @@ import { TimelineThreadsWithTotal } from 'src/engine/core-modules/messaging/dtos 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 { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; @Injectable() @@ -98,4 +99,41 @@ export class GetMessagesService { return messageThreads; } + + async getMessagesFromOpportunityId( + workspaceMemberId: string, + opportunityId: string, + page = 1, + pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE, + ): Promise { + const opportunityRepository = + await this.twentyORMManager.getRepository( + 'opportunity', + ); + + 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, + page, + pageSize, + ); + + return messageThreads; + } } diff --git a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts index e2558e634d7..f0e4722c5c8 100644 --- a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts @@ -41,6 +41,19 @@ class GetTimelineThreadsFromCompanyIdArgs { pageSize: number; } +@ArgsType() +class GetTimelineThreadsFromOpportunityIdArgs { + @Field(() => UUIDScalarType) + opportunityId: string; + + @Field(() => Int) + page: number; + + @Field(() => Int) + @Max(TIMELINE_THREADS_MAX_PAGE_SIZE) + pageSize: number; +} + @UseGuards(WorkspaceAuthGuard, UserAuthGuard) @Resolver(() => TimelineThreadsWithTotal) export class TimelineMessagingResolver { @@ -100,4 +113,31 @@ export class TimelineMessagingResolver { return timelineThreads; } + + @Query(() => TimelineThreadsWithTotal) + async getTimelineThreadsFromOpportunityId( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Args() + { opportunityId, page, pageSize }: GetTimelineThreadsFromOpportunityIdArgs, + ) { + const workspaceMember = await this.userService.loadWorkspaceMember( + user, + workspace, + ); + + if (!workspaceMember) { + return; + } + + const timelineThreads = + await this.getMessagesFromPersonIdsService.getMessagesFromOpportunityId( + workspaceMember.id, + opportunityId, + page, + pageSize, + ); + + return timelineThreads; + } }