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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user