feat(onboarding): prefill the invite step with teammates from the connected calendar (#21640)
## What & why Implements core-team-issues#1414: move the calendar/email connection earlier in onboarding and use the freshly connected calendar to prefill the **Invite your team** step with likely teammates, so users don't start from an empty form. ## Approach Everything is behind the feature flag `IS_ONBOARDING_INVITE_SUGGESTIONS_ENABLED` (off by default). **Reorder** — onboarding becomes `Workspace activation → Connect account → Create profile → Invite team`. Connecting before profile gives the calendar sync a head start; connecting *before* the workspace exists isn't possible (a connected account requires an activated workspace + workspace member + OAuth transient token). Gated in both `OnboardingService.getOnboardingStatus` (backend) and `useSetNextOnboardingStatus` (frontend) so the two agree. **Fast teammate lookup** — on Google/Microsoft connect *during onboarding*, a background job (`FetchOnboardingInviteSuggestionsJob`) runs a single bounded calendar fetch (recent events, attendees inline), keeps same-work-email-domain colleagues (excludes self + aliases; personal mailboxes yield nothing), ranks by meeting frequency, and caches the top 5. The invite step reads the cache via a new `getInviteSuggestions` query and prefills the form — polling briefly while the cache warms, and never overwriting input the user has already typed. Providers: **Google** (Calendar `events.list`) and **Microsoft** (Graph `calendarView`), routed by a `CalendarAttendeesService` dispatcher (mirrors the existing `CalendarGetCalendarEventsService`). Any fetch failure (missing scope, API error) degrades to today's empty form via the orchestrator's best-effort catch. ## How to enable Turn on `IS_ONBOARDING_INVITE_SUGGESTIONS_ENABLED` for a workspace (admin panel). ## Notes - New-workspace creators only (invitees never see the connect/invite steps). Skipping the connect step, or signing up with a personal email, falls back to the current empty form. - The "We found teammates from your calendar" subtitle only shows once suggestions are actually prefilled. - i18n: the new `<Trans>` strings are extracted on merge to `main` by the existing Crowdin workflow. ## Testing - Frontend unit tests for the reorder state machine (both flag states). - `npx nx typecheck` and `npx nx lint:diff-with-main` green for `twenty-front` and `twenty-server`. - Server boots with the new DI wiring (no circular dependency); `getInviteSuggestions` / `InviteSuggestion` present in the live metadata schema. - Not exercised in CI: live Google/Microsoft OAuth end-to-end (requires real accounts + calendar data). https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY --- _Generated by [Claude Code](https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21640?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: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: neo773 <neo773@protonmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1617,6 +1617,11 @@ type BillingUpdate {
|
||||
billingSubscriptions: [BillingSubscription!]!
|
||||
}
|
||||
|
||||
type InviteSuggestion {
|
||||
email: String!
|
||||
displayName: String
|
||||
}
|
||||
|
||||
type OnboardingStepSuccess {
|
||||
"""Boolean that confirms query was dispatched"""
|
||||
success: Boolean!
|
||||
@@ -3004,6 +3009,8 @@ type Query {
|
||||
getViewFieldGroup(id: String!): ViewFieldGroup
|
||||
apiKeys: [ApiKey!]!
|
||||
apiKey(input: GetApiKeyInput!): ApiKey
|
||||
getInviteSuggestions: [InviteSuggestion!]!
|
||||
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
|
||||
billingPortalSession(returnUrlPath: String): BillingSession!
|
||||
listPlans: [BillingPlan!]!
|
||||
getResourceCreditUsage: [BillingResourceCreditUsage!]!
|
||||
@@ -3013,7 +3020,6 @@ type Query {
|
||||
getPageLayoutTab(id: String!): PageLayoutTab!
|
||||
getPageLayouts(objectMetadataId: String, pageLayoutType: PageLayoutType): [PageLayout!]!
|
||||
getPageLayout(id: String!): PageLayout
|
||||
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
|
||||
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
|
||||
getPageLayoutWidget(id: String!): PageLayoutWidget!
|
||||
findOneLogicFunction(input: LogicFunctionIdInput!): LogicFunction!
|
||||
@@ -3253,6 +3259,7 @@ type Mutation {
|
||||
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
|
||||
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
|
||||
skipBookOnboardingStep: OnboardingStepSuccess!
|
||||
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
|
||||
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
|
||||
switchSubscriptionInterval: BillingUpdate!
|
||||
switchBillingPlan: BillingUpdate!
|
||||
@@ -3277,7 +3284,6 @@ type Mutation {
|
||||
resetPageLayoutToDefault(id: String!): PageLayout!
|
||||
resetPageLayoutWidgetToDefault(id: String!): PageLayoutWidget!
|
||||
resetPageLayoutTabToDefault(id: String!): PageLayoutTab!
|
||||
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
|
||||
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
|
||||
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
|
||||
destroyPageLayoutWidget(id: String!): Boolean!
|
||||
|
||||
@@ -1267,6 +1267,12 @@ export interface BillingUpdate {
|
||||
__typename: 'BillingUpdate'
|
||||
}
|
||||
|
||||
export interface InviteSuggestion {
|
||||
email: Scalars['String']
|
||||
displayName?: Scalars['String']
|
||||
__typename: 'InviteSuggestion'
|
||||
}
|
||||
|
||||
export interface OnboardingStepSuccess {
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success: Scalars['Boolean']
|
||||
@@ -2631,6 +2637,8 @@ export interface Query {
|
||||
getViewFieldGroup?: ViewFieldGroup
|
||||
apiKeys: ApiKey[]
|
||||
apiKey?: ApiKey
|
||||
getInviteSuggestions: InviteSuggestion[]
|
||||
applicationConnectionProviders: ApplicationConnectionProvider[]
|
||||
billingPortalSession: BillingSession
|
||||
listPlans: BillingPlan[]
|
||||
getResourceCreditUsage: BillingResourceCreditUsage[]
|
||||
@@ -2640,7 +2648,6 @@ export interface Query {
|
||||
getPageLayoutTab: PageLayoutTab
|
||||
getPageLayouts: PageLayout[]
|
||||
getPageLayout?: PageLayout
|
||||
applicationConnectionProviders: ApplicationConnectionProvider[]
|
||||
getPageLayoutWidgets: PageLayoutWidget[]
|
||||
getPageLayoutWidget: PageLayoutWidget
|
||||
findOneLogicFunction: LogicFunction
|
||||
@@ -2775,6 +2782,7 @@ export interface Mutation {
|
||||
assignRoleToApiKey: Scalars['Boolean']
|
||||
skipSyncEmailOnboardingStep: OnboardingStepSuccess
|
||||
skipBookOnboardingStep: OnboardingStepSuccess
|
||||
updateOneApplicationVariable: Scalars['Boolean']
|
||||
checkoutSession: BillingSession
|
||||
switchSubscriptionInterval: BillingUpdate
|
||||
switchBillingPlan: BillingUpdate
|
||||
@@ -2799,7 +2807,6 @@ export interface Mutation {
|
||||
resetPageLayoutToDefault: PageLayout
|
||||
resetPageLayoutWidgetToDefault: PageLayoutWidget
|
||||
resetPageLayoutTabToDefault: PageLayoutTab
|
||||
updateOneApplicationVariable: Scalars['Boolean']
|
||||
createPageLayoutWidget: PageLayoutWidget
|
||||
updatePageLayoutWidget: PageLayoutWidget
|
||||
destroyPageLayoutWidget: Scalars['Boolean']
|
||||
@@ -4280,6 +4287,13 @@ export interface BillingUpdateGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface InviteSuggestionGenqlSelection{
|
||||
email?: boolean | number
|
||||
displayName?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface OnboardingStepSuccessGenqlSelection{
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success?: boolean | number
|
||||
@@ -5732,6 +5746,8 @@ export interface QueryGenqlSelection{
|
||||
getViewFieldGroup?: (ViewFieldGroupGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
apiKeys?: ApiKeyGenqlSelection
|
||||
apiKey?: (ApiKeyGenqlSelection & { __args: {input: GetApiKeyInput} })
|
||||
getInviteSuggestions?: InviteSuggestionGenqlSelection
|
||||
applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
|
||||
billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null)} })
|
||||
listPlans?: BillingPlanGenqlSelection
|
||||
getResourceCreditUsage?: BillingResourceCreditUsageGenqlSelection
|
||||
@@ -5741,7 +5757,6 @@ export interface QueryGenqlSelection{
|
||||
getPageLayoutTab?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
getPageLayouts?: (PageLayoutGenqlSelection & { __args?: {objectMetadataId?: (Scalars['String'] | null), pageLayoutType?: (PageLayoutType | null)} })
|
||||
getPageLayout?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
|
||||
getPageLayoutWidgets?: (PageLayoutWidgetGenqlSelection & { __args: {pageLayoutTabId: Scalars['String']} })
|
||||
getPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
findOneLogicFunction?: (LogicFunctionGenqlSelection & { __args: {input: LogicFunctionIdInput} })
|
||||
@@ -5919,6 +5934,7 @@ export interface MutationGenqlSelection{
|
||||
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
|
||||
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
|
||||
skipBookOnboardingStep?: OnboardingStepSuccessGenqlSelection
|
||||
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
|
||||
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
|
||||
switchSubscriptionInterval?: BillingUpdateGenqlSelection
|
||||
switchBillingPlan?: BillingUpdateGenqlSelection
|
||||
@@ -5943,7 +5959,6 @@ export interface MutationGenqlSelection{
|
||||
resetPageLayoutToDefault?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
resetPageLayoutWidgetToDefault?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
resetPageLayoutTabToDefault?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
|
||||
createPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {input: CreatePageLayoutWidgetInput} })
|
||||
updatePageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String'], input: UpdatePageLayoutWidgetInput} })
|
||||
destroyPageLayoutWidget?: { __args: {id: Scalars['String']} }
|
||||
@@ -7267,6 +7282,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const InviteSuggestion_possibleTypes: string[] = ['InviteSuggestion']
|
||||
export const isInviteSuggestion = (obj?: { __typename?: any } | null): obj is InviteSuggestion => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isInviteSuggestion"')
|
||||
return InviteSuggestion_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const OnboardingStepSuccess_possibleTypes: string[] = ['OnboardingStepSuccess']
|
||||
export const isOnboardingStepSuccess = (obj?: { __typename?: any } | null): obj is OnboardingStepSuccess => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isOnboardingStepSuccess"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_INVITE_SUGGESTIONS = gql`
|
||||
query GetInviteSuggestions {
|
||||
getInviteSuggestions {
|
||||
email
|
||||
displayName
|
||||
}
|
||||
}
|
||||
`;
|
||||
+17
-17
@@ -83,47 +83,47 @@ describe('useSetNextOnboardingStatus', () => {
|
||||
resetJotaiStore();
|
||||
});
|
||||
|
||||
it('should set next onboarding status for ProfileCreation', () => {
|
||||
it('should sync emails right after workspace activation', () => {
|
||||
const nextOnboardingStatus = renderHooks(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
OnboardingStatus.WORKSPACE_ACTIVATION,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL);
|
||||
});
|
||||
|
||||
it('should skip SyncEmail when user is not first workspace member', () => {
|
||||
const nextOnboardingStatus = renderHooks(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('should skip SyncEmail when account sync is disabled', () => {
|
||||
const nextOnboardingStatus = renderHooks(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
OnboardingStatus.WORKSPACE_ACTIVATION,
|
||||
false,
|
||||
true,
|
||||
[PermissionFlagType.WORKSPACE_MEMBERS],
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.INVITE_TEAM);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION);
|
||||
});
|
||||
|
||||
it('should set next onboarding status for SyncEmail', () => {
|
||||
it('should create profile after syncing emails', () => {
|
||||
const nextOnboardingStatus = renderHooks(
|
||||
OnboardingStatus.SYNC_EMAIL,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION);
|
||||
});
|
||||
|
||||
it('should invite the team right after profile creation', () => {
|
||||
const nextOnboardingStatus = renderHooks(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.INVITE_TEAM);
|
||||
});
|
||||
|
||||
it('should skip invite when more than 1 workspaceMember exist', () => {
|
||||
it('should complete after profile creation when more than 1 workspaceMember exist', () => {
|
||||
const nextOnboardingStatus = renderHooks(
|
||||
OnboardingStatus.SYNC_EMAIL,
|
||||
true,
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
|
||||
@@ -33,23 +33,19 @@ const getNextOnboardingStatus = ({
|
||||
isAccountSyncEnabled,
|
||||
}: GetNextOnboardingStatusArgs) => {
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) {
|
||||
return isAccountSyncEnabled
|
||||
? OnboardingStatus.SYNC_EMAIL
|
||||
: OnboardingStatus.PROFILE_CREATION;
|
||||
}
|
||||
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.SYNC_EMAIL) {
|
||||
return OnboardingStatus.PROFILE_CREATION;
|
||||
}
|
||||
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.PROFILE_CREATION) {
|
||||
if (currentWorkspace?.workspaceMembersCount === 1) {
|
||||
if (isAccountSyncEnabled) {
|
||||
return OnboardingStatus.SYNC_EMAIL;
|
||||
}
|
||||
return OnboardingStatus.INVITE_TEAM;
|
||||
}
|
||||
return OnboardingStatus.COMPLETED;
|
||||
}
|
||||
if (
|
||||
currentUser?.onboardingStatus === OnboardingStatus.SYNC_EMAIL &&
|
||||
currentWorkspace?.workspaceMembersCount === 1
|
||||
) {
|
||||
return OnboardingStatus.INVITE_TEAM;
|
||||
return currentWorkspace?.workspaceMembersCount === 1
|
||||
? OnboardingStatus.INVITE_TEAM
|
||||
: OnboardingStatus.COMPLETED;
|
||||
}
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.INVITE_TEAM) {
|
||||
return isDefined(calendarBookingPageId)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotke
|
||||
import { styled } from '@linaria/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Controller,
|
||||
type SubmitHandler,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useForm,
|
||||
} from 'react-hook-form';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconCopy, SeparatorLineText } from 'twenty-ui-deprecated/display';
|
||||
@@ -26,6 +27,7 @@ import { LightButton, MainButton } from 'twenty-ui-deprecated/input';
|
||||
import { ClickToActionLink } from 'twenty-ui-deprecated/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
|
||||
import { z } from 'zod';
|
||||
import { GetInviteSuggestionsDocument } from '~/generated-metadata/graphql';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCreateWorkspaceInvitation';
|
||||
|
||||
@@ -74,7 +76,8 @@ export const InviteTeam = () => {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isValid, isSubmitting },
|
||||
reset,
|
||||
formState: { isValid, isSubmitting, isDirty },
|
||||
} = useForm<FormInput>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
@@ -88,6 +91,41 @@ export const InviteTeam = () => {
|
||||
name: 'emails',
|
||||
});
|
||||
|
||||
const [hasPrefilledSuggestions, setHasPrefilledSuggestions] = useState(false);
|
||||
|
||||
const { data: inviteSuggestionsData } = useQuery(
|
||||
GetInviteSuggestionsDocument,
|
||||
{
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
);
|
||||
|
||||
const inviteSuggestions = useMemo(
|
||||
() => inviteSuggestionsData?.getInviteSuggestions ?? [],
|
||||
[inviteSuggestionsData],
|
||||
);
|
||||
const hasInviteSuggestions = inviteSuggestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPrefilledSuggestions || !hasInviteSuggestions || isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasPrefilledSuggestions(true);
|
||||
reset({
|
||||
emails: [
|
||||
...inviteSuggestions.map((suggestion) => ({ email: suggestion.email })),
|
||||
{ email: '' },
|
||||
],
|
||||
});
|
||||
}, [
|
||||
hasPrefilledSuggestions,
|
||||
hasInviteSuggestions,
|
||||
inviteSuggestions,
|
||||
isDirty,
|
||||
reset,
|
||||
]);
|
||||
|
||||
watch(({ emails }) => {
|
||||
if (!emails) {
|
||||
return;
|
||||
@@ -170,7 +208,15 @@ export const InviteTeam = () => {
|
||||
<Trans>Invite your team</Trans>
|
||||
</Title>
|
||||
<SubTitle>
|
||||
<Trans>Get the most out of your workspace by inviting your team.</Trans>
|
||||
{hasPrefilledSuggestions ? (
|
||||
<Trans>
|
||||
We found teammates from your calendar. Review and invite them.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Get the most out of your workspace by inviting your team.
|
||||
</Trans>
|
||||
)}
|
||||
</SubTitle>
|
||||
<StyledAnimatedContainer>
|
||||
{fields.map((field, index) => (
|
||||
|
||||
+7
@@ -98,6 +98,12 @@ export class GoogleAPIsAuthController {
|
||||
|
||||
const handle = emails[0].value.toLowerCase();
|
||||
|
||||
const shouldComputeInviteSuggestions =
|
||||
await this.onboardingService.shouldComputeInviteSuggestionsOnConnect({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const connectedAccountId =
|
||||
await this.googleAPIsService.refreshGoogleRefreshToken({
|
||||
handle,
|
||||
@@ -109,6 +115,7 @@ export class GoogleAPIsAuthController {
|
||||
calendarVisibility,
|
||||
messageVisibility,
|
||||
skipMessageChannelConfiguration,
|
||||
shouldComputeInviteSuggestions,
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
|
||||
+7
@@ -105,6 +105,12 @@ export class MicrosoftAPIsAuthController {
|
||||
|
||||
const handle = emails[0].value.toLowerCase();
|
||||
|
||||
const shouldComputeInviteSuggestions =
|
||||
await this.onboardingService.shouldComputeInviteSuggestionsOnConnect({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const connectedAccountId =
|
||||
await this.microsoftAPIsService.refreshMicrosoftRefreshToken({
|
||||
handle,
|
||||
@@ -116,6 +122,7 @@ export class MicrosoftAPIsAuthController {
|
||||
calendarVisibility,
|
||||
messageVisibility,
|
||||
skipMessageChannelConfiguration,
|
||||
shouldComputeInviteSuggestions,
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
|
||||
@@ -47,6 +47,10 @@ import {
|
||||
MessagingMessageListFetchJob,
|
||||
type MessagingMessageListFetchJobData,
|
||||
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
|
||||
import {
|
||||
FetchOnboardingInviteSuggestionsJob,
|
||||
type FetchOnboardingInviteSuggestionsJobData,
|
||||
} from 'src/modules/onboarding-invite-suggestions/jobs/fetch-onboarding-invite-suggestions.job';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
@@ -89,6 +93,7 @@ export class GoogleAPIsService {
|
||||
calendarVisibility: CalendarChannelVisibility | undefined;
|
||||
messageVisibility: MessageChannelVisibility | undefined;
|
||||
skipMessageChannelConfiguration?: boolean;
|
||||
shouldComputeInviteSuggestions?: boolean;
|
||||
}): Promise<string> {
|
||||
const {
|
||||
handle,
|
||||
@@ -98,6 +103,7 @@ export class GoogleAPIsService {
|
||||
calendarVisibility,
|
||||
messageVisibility,
|
||||
skipMessageChannelConfiguration,
|
||||
shouldComputeInviteSuggestions,
|
||||
} = input;
|
||||
|
||||
const isCalendarEnabled = this.twentyConfigService.get(
|
||||
@@ -347,6 +353,23 @@ export class GoogleAPIsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
shouldComputeInviteSuggestions &&
|
||||
isCalendarEnabled &&
|
||||
isCalendarAvailable
|
||||
) {
|
||||
void this.calendarQueueService
|
||||
.add<FetchOnboardingInviteSuggestionsJobData>(
|
||||
FetchOnboardingInviteSuggestionsJob.name,
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
connectedAccountId: newOrExistingConnectedAccountId,
|
||||
},
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return newOrExistingConnectedAccountId;
|
||||
},
|
||||
authContext,
|
||||
|
||||
+32
-9
@@ -39,6 +39,10 @@ import {
|
||||
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
|
||||
import { EmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/services/email-alias-manager.service';
|
||||
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
|
||||
import {
|
||||
FetchOnboardingInviteSuggestionsJob,
|
||||
type FetchOnboardingInviteSuggestionsJobData,
|
||||
} from 'src/modules/onboarding-invite-suggestions/jobs/fetch-onboarding-invite-suggestions.job';
|
||||
|
||||
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
|
||||
import {
|
||||
@@ -85,6 +89,7 @@ export class MicrosoftAPIsService {
|
||||
calendarVisibility: CalendarChannelVisibility | undefined;
|
||||
messageVisibility: MessageChannelVisibility | undefined;
|
||||
skipMessageChannelConfiguration?: boolean;
|
||||
shouldComputeInviteSuggestions?: boolean;
|
||||
}): Promise<string> {
|
||||
const {
|
||||
handle,
|
||||
@@ -94,6 +99,7 @@ export class MicrosoftAPIsService {
|
||||
calendarVisibility,
|
||||
messageVisibility,
|
||||
skipMessageChannelConfiguration,
|
||||
shouldComputeInviteSuggestions,
|
||||
} = input;
|
||||
|
||||
const scopes = getMicrosoftApisOauthScopes();
|
||||
@@ -295,19 +301,36 @@ export class MicrosoftAPIsService {
|
||||
},
|
||||
});
|
||||
|
||||
for (const calendarChannel of calendarChannels) {
|
||||
if (
|
||||
const syncableCalendarChannels = calendarChannels.filter(
|
||||
(calendarChannel) =>
|
||||
calendarChannel.syncStage !==
|
||||
CalendarChannelSyncStage.PENDING_CONFIGURATION
|
||||
) {
|
||||
await this.calendarQueueService.add<CalendarEventListFetchJobData>(
|
||||
CalendarEventListFetchJob.name,
|
||||
CalendarChannelSyncStage.PENDING_CONFIGURATION,
|
||||
);
|
||||
|
||||
for (const calendarChannel of syncableCalendarChannels) {
|
||||
await this.calendarQueueService.add<CalendarEventListFetchJobData>(
|
||||
CalendarEventListFetchJob.name,
|
||||
{
|
||||
calendarChannelId: calendarChannel.id,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
shouldComputeInviteSuggestions &&
|
||||
syncableCalendarChannels.length > 0
|
||||
) {
|
||||
void this.calendarQueueService
|
||||
.add<FetchOnboardingInviteSuggestionsJobData>(
|
||||
FetchOnboardingInviteSuggestionsJob.name,
|
||||
{
|
||||
calendarChannelId: calendarChannel.id,
|
||||
workspaceId,
|
||||
userId,
|
||||
connectedAccountId: newOrExistingConnectedAccountId,
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -9,5 +9,6 @@ export enum CacheStorageNamespace {
|
||||
EngineMetrics = 'engine:metrics',
|
||||
EngineSubscriptions = 'engine:subscriptions',
|
||||
EngineBillingUsage = 'engine:billing-usage',
|
||||
EngineOnboardingInviteSuggestions = 'engine:onboarding-invite-suggestions',
|
||||
IntegrationTests = 'integration-tests',
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/cale
|
||||
import { CalendarModule } from 'src/modules/calendar/calendar.module';
|
||||
import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module';
|
||||
import { MessagingModule } from 'src/modules/messaging/messaging.module';
|
||||
import { OnboardingInviteSuggestionsModule } from 'src/modules/onboarding-invite-suggestions/onboarding-invite-suggestions.module';
|
||||
import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module';
|
||||
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
|
||||
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
@@ -66,6 +67,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
MessagingModule,
|
||||
CalendarModule,
|
||||
CalendarEventParticipantManagerModule,
|
||||
OnboardingInviteSuggestionsModule,
|
||||
TimelineActivityModule,
|
||||
StripeModule,
|
||||
FeatureFlagModule,
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('InviteSuggestion')
|
||||
export class InviteSuggestionDTO {
|
||||
@Field(() => String)
|
||||
email: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
displayName?: string;
|
||||
}
|
||||
@@ -2,17 +2,17 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { OnboardingResolver } from 'src/engine/core-modules/onboarding/onboarding.resolver';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { OnboardingInviteSuggestionsModule } from 'src/modules/onboarding-invite-suggestions/onboarding-invite-suggestions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BillingModule,
|
||||
UserVarsModule,
|
||||
FeatureFlagModule,
|
||||
OnboardingInviteSuggestionsModule,
|
||||
TypeOrmModule.forFeature([WorkspaceEntity]),
|
||||
],
|
||||
exports: [OnboardingService],
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Mutation } from '@nestjs/graphql';
|
||||
import { Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { InviteSuggestionDTO } from 'src/engine/core-modules/onboarding/dtos/invite-suggestion.dto';
|
||||
import { OnboardingStepSuccessDTO } from 'src/engine/core-modules/onboarding/dtos/onboarding-step-success.dto';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { OnboardingInviteSuggestionsService } from 'src/modules/onboarding-invite-suggestions/services/onboarding-invite-suggestions.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
@@ -19,7 +21,22 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@MetadataResolver()
|
||||
export class OnboardingResolver {
|
||||
constructor(private readonly onboardingService: OnboardingService) {}
|
||||
constructor(
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private readonly onboardingInviteSuggestionsService: OnboardingInviteSuggestionsService,
|
||||
) {}
|
||||
|
||||
@Query(() => [InviteSuggestionDTO])
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async getInviteSuggestions(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<InviteSuggestionDTO[]> {
|
||||
return this.onboardingInviteSuggestionsService.getCachedSuggestions({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => OnboardingStepSuccessDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
|
||||
@@ -94,14 +94,14 @@ export class OnboardingService {
|
||||
userVars.get(OnboardingStepKeys.ONBOARDING_BOOK_ONBOARDING_PENDING) ===
|
||||
true;
|
||||
|
||||
if (isProfileCreationPending) {
|
||||
return OnboardingStatus.PROFILE_CREATION;
|
||||
}
|
||||
|
||||
if (isConnectAccountPending) {
|
||||
return OnboardingStatus.SYNC_EMAIL;
|
||||
}
|
||||
|
||||
if (isProfileCreationPending) {
|
||||
return OnboardingStatus.PROFILE_CREATION;
|
||||
}
|
||||
|
||||
if (isInviteTeamPending) {
|
||||
return OnboardingStatus.INVITE_TEAM;
|
||||
}
|
||||
@@ -129,6 +129,43 @@ export class OnboardingService {
|
||||
return OnboardingStatus.COMPLETED;
|
||||
}
|
||||
|
||||
async isOnboardingConnectAccountPending({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const value = await this.userVarsService.get({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING,
|
||||
});
|
||||
|
||||
return value === true;
|
||||
}
|
||||
|
||||
async shouldComputeInviteSuggestionsOnConnect({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
if (!isDefined(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.isOnboardingConnectAccountPending({
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async setOnboardingConnectAccountPending(
|
||||
{
|
||||
userId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
||||
import { CalendarModule } from 'src/modules/calendar/calendar.module';
|
||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
|
||||
import { MessagingModule } from 'src/modules/messaging/messaging.module';
|
||||
import { OnboardingInviteSuggestionsModule } from 'src/modules/onboarding-invite-suggestions/onboarding-invite-suggestions.module';
|
||||
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
import { WorkspaceMemberModule } from 'src/modules/workspace-member/workspace-member.module';
|
||||
|
||||
@@ -11,6 +12,7 @@ import { WorkspaceMemberModule } from 'src/modules/workspace-member/workspace-me
|
||||
MessagingModule,
|
||||
CalendarModule,
|
||||
ConnectedAccountModule,
|
||||
OnboardingInviteSuggestionsModule,
|
||||
WorkflowModule,
|
||||
WorkspaceMemberModule,
|
||||
],
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const ONBOARDING_INVITE_SUGGESTIONS_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const ONBOARDING_INVITE_SUGGESTIONS_LOOKAHEAD_DAYS = 7;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const ONBOARDING_INVITE_SUGGESTIONS_LOOKBACK_DAYS = 90;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const ONBOARDING_INVITE_SUGGESTIONS_MAX_COUNT = 5;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const ONBOARDING_INVITE_SUGGESTIONS_MAX_EVENTS = 250;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { OnboardingInviteSuggestionsService } from 'src/modules/onboarding-invite-suggestions/services/onboarding-invite-suggestions.service';
|
||||
|
||||
export type FetchOnboardingInviteSuggestionsJobData = {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
connectedAccountId: string;
|
||||
};
|
||||
|
||||
@Processor({
|
||||
queueName: MessageQueue.calendarQueue,
|
||||
})
|
||||
export class FetchOnboardingInviteSuggestionsJob {
|
||||
constructor(
|
||||
private readonly onboardingInviteSuggestionsService: OnboardingInviteSuggestionsService,
|
||||
) {}
|
||||
|
||||
@Process(FetchOnboardingInviteSuggestionsJob.name)
|
||||
async handle(data: FetchOnboardingInviteSuggestionsJobData): Promise<void> {
|
||||
await this.onboardingInviteSuggestionsService.computeAndCacheSuggestions(
|
||||
data,
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
|
||||
import { FetchOnboardingInviteSuggestionsJob } from 'src/modules/onboarding-invite-suggestions/jobs/fetch-onboarding-invite-suggestions.job';
|
||||
import { CalendarAttendeesService } from 'src/modules/onboarding-invite-suggestions/services/calendar-attendees.service';
|
||||
import { GoogleCalendarAttendeesService } from 'src/modules/onboarding-invite-suggestions/services/google-calendar-attendees.service';
|
||||
import { MicrosoftCalendarAttendeesService } from 'src/modules/onboarding-invite-suggestions/services/microsoft-calendar-attendees.service';
|
||||
import { OnboardingInviteSuggestionsService } from 'src/modules/onboarding-invite-suggestions/services/onboarding-invite-suggestions.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
OAuth2ClientManagerModule,
|
||||
TypeOrmModule.forFeature([ConnectedAccountEntity]),
|
||||
],
|
||||
providers: [
|
||||
GoogleCalendarAttendeesService,
|
||||
MicrosoftCalendarAttendeesService,
|
||||
CalendarAttendeesService,
|
||||
OnboardingInviteSuggestionsService,
|
||||
FetchOnboardingInviteSuggestionsJob,
|
||||
],
|
||||
exports: [OnboardingInviteSuggestionsService],
|
||||
})
|
||||
export class OnboardingInviteSuggestionsModule {}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GoogleCalendarAttendeesService } from 'src/modules/onboarding-invite-suggestions/services/google-calendar-attendees.service';
|
||||
import { MicrosoftCalendarAttendeesService } from 'src/modules/onboarding-invite-suggestions/services/microsoft-calendar-attendees.service';
|
||||
import { type CalendarAttendee } from 'src/modules/onboarding-invite-suggestions/types/calendar-attendee.type';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarAttendeesService {
|
||||
constructor(
|
||||
private readonly googleCalendarAttendeesService: GoogleCalendarAttendeesService,
|
||||
private readonly microsoftCalendarAttendeesService: MicrosoftCalendarAttendeesService,
|
||||
) {}
|
||||
|
||||
async getRecentAttendees(
|
||||
connectedAccount: Pick<ConnectedAccountEntity, 'provider' | 'id'>,
|
||||
): Promise<CalendarAttendee[]> {
|
||||
switch (connectedAccount.provider) {
|
||||
case ConnectedAccountProvider.GOOGLE:
|
||||
return this.googleCalendarAttendeesService.getRecentAttendees(
|
||||
connectedAccount.id,
|
||||
);
|
||||
case ConnectedAccountProvider.MICROSOFT:
|
||||
return this.microsoftCalendarAttendeesService.getRecentAttendees(
|
||||
connectedAccount.id,
|
||||
);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { google } from 'googleapis';
|
||||
|
||||
import { GoogleOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client.provider';
|
||||
import { ONBOARDING_INVITE_SUGGESTIONS_LOOKAHEAD_DAYS } from 'src/modules/onboarding-invite-suggestions/constants/onboarding-invite-suggestions-lookahead-days.constant';
|
||||
import { ONBOARDING_INVITE_SUGGESTIONS_LOOKBACK_DAYS } from 'src/modules/onboarding-invite-suggestions/constants/onboarding-invite-suggestions-lookback-days.constant';
|
||||
import { ONBOARDING_INVITE_SUGGESTIONS_MAX_EVENTS } from 'src/modules/onboarding-invite-suggestions/constants/onboarding-invite-suggestions-max-events.constant';
|
||||
import { type CalendarAttendee } from 'src/modules/onboarding-invite-suggestions/types/calendar-attendee.type';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarAttendeesService {
|
||||
constructor(
|
||||
private readonly googleOAuth2ClientProvider: GoogleOAuth2ClientProvider,
|
||||
) {}
|
||||
|
||||
async getRecentAttendees(
|
||||
connectedAccountId: string,
|
||||
): Promise<CalendarAttendee[]> {
|
||||
const oAuth2Client =
|
||||
await this.googleOAuth2ClientProvider.getClient(connectedAccountId);
|
||||
|
||||
const googleCalendarClient = google.calendar({
|
||||
version: 'v3',
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const response = await googleCalendarClient.events.list({
|
||||
calendarId: 'primary',
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
maxResults: ONBOARDING_INVITE_SUGGESTIONS_MAX_EVENTS,
|
||||
timeMin: new Date(
|
||||
now - ONBOARDING_INVITE_SUGGESTIONS_LOOKBACK_DAYS * MS_PER_DAY,
|
||||
).toISOString(),
|
||||
timeMax: new Date(
|
||||
now + ONBOARDING_INVITE_SUGGESTIONS_LOOKAHEAD_DAYS * MS_PER_DAY,
|
||||
).toISOString(),
|
||||
});
|
||||
|
||||
const events = response.data.items ?? [];
|
||||
const attendees: CalendarAttendee[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
const displayNameByEmail = new Map<string, string | undefined>();
|
||||
|
||||
const organizerEmail = event.organizer?.email?.toLowerCase();
|
||||
|
||||
if (organizerEmail) {
|
||||
displayNameByEmail.set(
|
||||
organizerEmail,
|
||||
event.organizer?.displayName ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
for (const attendee of event.attendees ?? []) {
|
||||
const attendeeEmail = attendee.email?.toLowerCase();
|
||||
const isRoomOrResource = attendee.resource === true;
|
||||
|
||||
if (
|
||||
!attendeeEmail ||
|
||||
isRoomOrResource ||
|
||||
displayNameByEmail.has(attendeeEmail)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
displayNameByEmail.set(
|
||||
attendeeEmail,
|
||||
attendee.displayName ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [email, displayName] of displayNameByEmail) {
|
||||
attendees.push({ email, displayName });
|
||||
}
|
||||
}
|
||||
|
||||
return attendees;
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { MicrosoftOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client.provider';
|
||||
import { ONBOARDING_INVITE_SUGGESTIONS_LOOKAHEAD_DAYS } from 'src/modules/onboarding-invite-suggestions/constants/onboarding-invite-suggestions-lookahead-days.constant';
|
||||
import { ONBOARDING_INVITE_SUGGESTIONS_LOOKBACK_DAYS } from 'src/modules/onboarding-invite-suggestions/constants/onboarding-invite-suggestions-lookback-days.constant';
|
||||
import { ONBOARDING_INVITE_SUGGESTIONS_MAX_EVENTS } from 'src/modules/onboarding-invite-suggestions/constants/onboarding-invite-suggestions-max-events.constant';
|
||||
import { type CalendarAttendee } from 'src/modules/onboarding-invite-suggestions/types/calendar-attendee.type';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
type MicrosoftEmailAddress = {
|
||||
address?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
type MicrosoftCalendarViewResponse = {
|
||||
value?: {
|
||||
organizer?: { emailAddress?: MicrosoftEmailAddress | null } | null;
|
||||
attendees?:
|
||||
| {
|
||||
type?: string | null;
|
||||
emailAddress?: MicrosoftEmailAddress | null;
|
||||
}[]
|
||||
| null;
|
||||
}[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftCalendarAttendeesService {
|
||||
constructor(
|
||||
private readonly microsoftOAuth2ClientProvider: MicrosoftOAuth2ClientProvider,
|
||||
) {}
|
||||
|
||||
async getRecentAttendees(
|
||||
connectedAccountId: string,
|
||||
): Promise<CalendarAttendee[]> {
|
||||
const microsoftClient =
|
||||
await this.microsoftOAuth2ClientProvider.getClient(connectedAccountId);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const response: MicrosoftCalendarViewResponse = await microsoftClient
|
||||
.api('/me/calendarView')
|
||||
.query({
|
||||
startDateTime: new Date(
|
||||
now - ONBOARDING_INVITE_SUGGESTIONS_LOOKBACK_DAYS * MS_PER_DAY,
|
||||
).toISOString(),
|
||||
endDateTime: new Date(
|
||||
now + ONBOARDING_INVITE_SUGGESTIONS_LOOKAHEAD_DAYS * MS_PER_DAY,
|
||||
).toISOString(),
|
||||
})
|
||||
.select('organizer,attendees')
|
||||
.top(ONBOARDING_INVITE_SUGGESTIONS_MAX_EVENTS)
|
||||
.get();
|
||||
|
||||
const events = response.value ?? [];
|
||||
const attendees: CalendarAttendee[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
const displayNameByEmail = new Map<string, string | undefined>();
|
||||
|
||||
const organizerEmail =
|
||||
event.organizer?.emailAddress?.address?.toLowerCase();
|
||||
|
||||
if (organizerEmail) {
|
||||
displayNameByEmail.set(
|
||||
organizerEmail,
|
||||
event.organizer?.emailAddress?.name ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
for (const attendee of event.attendees ?? []) {
|
||||
const attendeeEmail = attendee.emailAddress?.address?.toLowerCase();
|
||||
const isRoomOrResource = attendee.type === 'resource';
|
||||
|
||||
if (
|
||||
!attendeeEmail ||
|
||||
isRoomOrResource ||
|
||||
displayNameByEmail.has(attendeeEmail)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
displayNameByEmail.set(
|
||||
attendeeEmail,
|
||||
attendee.emailAddress?.name ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [email, displayName] of displayNameByEmail) {
|
||||
attendees.push({ email, displayName });
|
||||
}
|
||||
}
|
||||
|
||||
return attendees;
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { getDomainNameFromHandle } from 'src/modules/contact-creation-manager/utils/get-domain-name-from-handle.util';
|
||||
import { ONBOARDING_INVITE_SUGGESTIONS_CACHE_TTL_MS } from 'src/modules/onboarding-invite-suggestions/constants/onboarding-invite-suggestions-cache-ttl-ms.constant';
|
||||
import { ONBOARDING_INVITE_SUGGESTIONS_MAX_COUNT } from 'src/modules/onboarding-invite-suggestions/constants/onboarding-invite-suggestions-max-count.constant';
|
||||
import { getOnboardingInviteSuggestionsCacheKey } from 'src/modules/onboarding-invite-suggestions/utils/get-onboarding-invite-suggestions-cache-key.util';
|
||||
import { isGroupEmail } from 'src/modules/messaging/message-import-manager/utils/is-group-email';
|
||||
import { CalendarAttendeesService } from 'src/modules/onboarding-invite-suggestions/services/calendar-attendees.service';
|
||||
import { type CalendarAttendee } from 'src/modules/onboarding-invite-suggestions/types/calendar-attendee.type';
|
||||
import { isWorkEmail } from 'src/utils/is-work-email';
|
||||
|
||||
type ComputeAndCacheSuggestionsArgs = {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
connectedAccountId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OnboardingInviteSuggestionsService {
|
||||
private readonly logger = new Logger(OnboardingInviteSuggestionsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
private readonly calendarAttendeesService: CalendarAttendeesService,
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineOnboardingInviteSuggestions)
|
||||
private readonly cacheStorageService: CacheStorageService,
|
||||
) {}
|
||||
|
||||
async getCachedSuggestions({
|
||||
workspaceId,
|
||||
userId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
}): Promise<CalendarAttendee[]> {
|
||||
const cachedSuggestions = await this.cacheStorageService.get<
|
||||
CalendarAttendee[]
|
||||
>(getOnboardingInviteSuggestionsCacheKey(workspaceId, userId));
|
||||
|
||||
return cachedSuggestions ?? [];
|
||||
}
|
||||
|
||||
async computeAndCacheSuggestions({
|
||||
workspaceId,
|
||||
userId,
|
||||
connectedAccountId,
|
||||
}: ComputeAndCacheSuggestionsArgs): Promise<void> {
|
||||
const cacheKey = getOnboardingInviteSuggestionsCacheKey(
|
||||
workspaceId,
|
||||
userId,
|
||||
);
|
||||
|
||||
const connectedAccount = await this.connectedAccountRepository.findOne({
|
||||
where: { id: connectedAccountId, workspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(connectedAccount)) {
|
||||
await this.cacheStorageService.set<CalendarAttendee[]>(
|
||||
cacheKey,
|
||||
[],
|
||||
ONBOARDING_INVITE_SUGGESTIONS_CACHE_TTL_MS,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedAccountHandle = connectedAccount.handle.toLowerCase();
|
||||
|
||||
if (!isWorkEmail(connectedAccountHandle)) {
|
||||
await this.cacheStorageService.set<CalendarAttendee[]>(
|
||||
cacheKey,
|
||||
[],
|
||||
ONBOARDING_INVITE_SUGGESTIONS_CACHE_TTL_MS,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedAccountDomain = getDomainNameFromHandle(
|
||||
connectedAccountHandle,
|
||||
);
|
||||
const ownEmailHandles = new Set<string>([
|
||||
connectedAccountHandle,
|
||||
...(connectedAccount.handleAliases ?? []).map((alias) =>
|
||||
alias.toLowerCase(),
|
||||
),
|
||||
]);
|
||||
|
||||
let attendees: CalendarAttendee[] = [];
|
||||
|
||||
try {
|
||||
attendees =
|
||||
await this.calendarAttendeesService.getRecentAttendees(
|
||||
connectedAccount,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Could not compute invite suggestions for workspace ${workspaceId}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const eventCountByColleagueEmail = new Map<
|
||||
string,
|
||||
{ eventCount: number; displayName?: string }
|
||||
>();
|
||||
|
||||
for (const attendee of attendees) {
|
||||
const attendeeEmail = attendee.email.toLowerCase();
|
||||
|
||||
const isOwnEmail = ownEmailHandles.has(attendeeEmail);
|
||||
const isSameCompanyColleague =
|
||||
getDomainNameFromHandle(attendeeEmail) === connectedAccountDomain;
|
||||
|
||||
if (
|
||||
isOwnEmail ||
|
||||
!isSameCompanyColleague ||
|
||||
isGroupEmail(attendeeEmail)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const colleagueTally = eventCountByColleagueEmail.get(attendeeEmail) ?? {
|
||||
eventCount: 0,
|
||||
};
|
||||
|
||||
colleagueTally.eventCount += 1;
|
||||
|
||||
if (
|
||||
!isDefined(colleagueTally.displayName) &&
|
||||
isDefined(attendee.displayName)
|
||||
) {
|
||||
colleagueTally.displayName = attendee.displayName;
|
||||
}
|
||||
|
||||
eventCountByColleagueEmail.set(attendeeEmail, colleagueTally);
|
||||
}
|
||||
|
||||
const mostFrequentColleaguesFirst = Array.from(
|
||||
eventCountByColleagueEmail.entries(),
|
||||
).sort(([, left], [, right]) => right.eventCount - left.eventCount);
|
||||
|
||||
const suggestions: CalendarAttendee[] = mostFrequentColleaguesFirst
|
||||
.slice(0, ONBOARDING_INVITE_SUGGESTIONS_MAX_COUNT)
|
||||
.map(([email, { displayName }]) => ({ email, displayName }));
|
||||
|
||||
await this.cacheStorageService.set<CalendarAttendee[]>(
|
||||
cacheKey,
|
||||
suggestions,
|
||||
ONBOARDING_INVITE_SUGGESTIONS_CACHE_TTL_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type CalendarAttendee = {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const getOnboardingInviteSuggestionsCacheKey = (
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): string => `${workspaceId}:${userId}`;
|
||||
Reference in New Issue
Block a user