perf(onboarding): compute invite suggestions on-demand (#21696)

## Summary

Follow-up to #21640. In production, invite suggestions took ~1 minute to
appear because `FetchOnboardingInviteSuggestionsJob` ran on the shared
`calendarQueue` behind heavy calendar-sync jobs.

- **Drop the background job entirely.** `getInviteSuggestions` now
resolves the connected account from the authenticated
`@AuthUserWorkspaceId()` and computes suggestions on demand:
cache-first, with a bounded calendar fetch + cache write on a miss.
Removes the Google/Microsoft enqueues, the
`shouldComputeInviteSuggestions` threading through the auth controllers,
and the now-unused `shouldComputeInviteSuggestionsOnConnect` /
`isOnboardingConnectAccountPending` helpers.
- **Prefetch one step earlier.** New `usePrefetchInviteSuggestions` hook
fires the query from `CreateProfile` so the server cache is warm by the
time the invite step renders. `InviteTeam` switches from `network-only`
→ `cache-first`. If the profile step is skipped, the invite step still
computes on-demand (~1–3s, no queue) — no more minute-long waits.

No GraphQL schema change.

## Test plan

- [ ] Connect Google calendar in onboarding → invite step renders
prefilled teammates with no perceivable wait
- [ ] Connect Microsoft calendar in onboarding → same
- [ ] Onboard with workspace name already set so profile step is skipped
→ invite step still prefills (just with a brief on-demand fetch instead
of 1 min)
- [ ] Connect a non-work-email account → invite step renders empty form
(no suggestions)
- [ ] `npx nx typecheck twenty-server` 
- [ ] `npx nx lint:diff-with-main twenty-server` 
- [ ] `npx nx lint:diff-with-main twenty-front`  (changed files clean)
- [ ] `google-apis.service.spec.ts` + `microsoft-apis.service.spec.ts`
pass

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/21696?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: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-16 21:25:02 +02:00
committed by GitHub
parent e50ec75cd0
commit 35c2a24afb
13 changed files with 66 additions and 180 deletions
@@ -0,0 +1,9 @@
import { useQuery } from '@apollo/client/react';
import { GetInviteSuggestionsDocument } from '~/generated-metadata/graphql';
export const usePrefetchInviteSuggestions = () => {
useQuery(GetInviteSuggestionsDocument, {
fetchPolicy: 'cache-first',
});
};
@@ -12,6 +12,7 @@ import { Title } from '@/auth/components/Title';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { usePrefetchInviteSuggestions } from '@/onboarding/hooks/usePrefetchInviteSuggestions';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { WorkspaceMemberPictureUploader } from '@/settings/workspace-member/components/WorkspaceMemberPictureUploader';
@@ -69,6 +70,9 @@ type Form = z.infer<typeof validationSchema>;
export const CreateProfile = () => {
const { t } = useLingui();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
usePrefetchInviteSuggestions();
const { enqueueErrorSnackBar } = useSnackBar();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const setCurrentUser = useSetAtomState(currentUserState);
@@ -96,7 +96,7 @@ export const InviteTeam = () => {
const { data: inviteSuggestionsData } = useQuery(
GetInviteSuggestionsDocument,
{
fetchPolicy: 'network-only',
fetchPolicy: 'cache-first',
},
);
@@ -98,12 +98,6 @@ export class GoogleAPIsAuthController {
const handle = emails[0].value.toLowerCase();
const shouldComputeInviteSuggestions =
await this.onboardingService.shouldComputeInviteSuggestionsOnConnect({
userId,
workspaceId,
});
const connectedAccountId =
await this.googleAPIsService.refreshGoogleRefreshToken({
handle,
@@ -115,7 +109,6 @@ export class GoogleAPIsAuthController {
calendarVisibility,
messageVisibility,
skipMessageChannelConfiguration,
shouldComputeInviteSuggestions,
});
if (userId) {
@@ -105,12 +105,6 @@ export class MicrosoftAPIsAuthController {
const handle = emails[0].value.toLowerCase();
const shouldComputeInviteSuggestions =
await this.onboardingService.shouldComputeInviteSuggestionsOnConnect({
userId,
workspaceId,
});
const connectedAccountId =
await this.microsoftAPIsService.refreshMicrosoftRefreshToken({
handle,
@@ -122,7 +116,6 @@ export class MicrosoftAPIsAuthController {
calendarVisibility,
messageVisibility,
skipMessageChannelConfiguration,
shouldComputeInviteSuggestions,
});
if (userId) {
@@ -47,10 +47,6 @@ 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()
@@ -93,7 +89,6 @@ export class GoogleAPIsService {
calendarVisibility: CalendarChannelVisibility | undefined;
messageVisibility: MessageChannelVisibility | undefined;
skipMessageChannelConfiguration?: boolean;
shouldComputeInviteSuggestions?: boolean;
}): Promise<string> {
const {
handle,
@@ -103,7 +98,6 @@ export class GoogleAPIsService {
calendarVisibility,
messageVisibility,
skipMessageChannelConfiguration,
shouldComputeInviteSuggestions,
} = input;
const isCalendarEnabled = this.twentyConfigService.get(
@@ -353,23 +347,6 @@ export class GoogleAPIsService {
}
}
if (
shouldComputeInviteSuggestions &&
isCalendarEnabled &&
isCalendarAvailable
) {
void this.calendarQueueService
.add<FetchOnboardingInviteSuggestionsJobData>(
FetchOnboardingInviteSuggestionsJob.name,
{
workspaceId,
userId,
connectedAccountId: newOrExistingConnectedAccountId,
},
)
.catch(() => undefined);
}
return newOrExistingConnectedAccountId;
},
authContext,
@@ -39,10 +39,6 @@ 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 {
@@ -89,7 +85,6 @@ export class MicrosoftAPIsService {
calendarVisibility: CalendarChannelVisibility | undefined;
messageVisibility: MessageChannelVisibility | undefined;
skipMessageChannelConfiguration?: boolean;
shouldComputeInviteSuggestions?: boolean;
}): Promise<string> {
const {
handle,
@@ -99,7 +94,6 @@ export class MicrosoftAPIsService {
calendarVisibility,
messageVisibility,
skipMessageChannelConfiguration,
shouldComputeInviteSuggestions,
} = input;
const scopes = getMicrosoftApisOauthScopes();
@@ -316,22 +310,6 @@ export class MicrosoftAPIsService {
},
);
}
if (
shouldComputeInviteSuggestions &&
syncableCalendarChannels.length > 0
) {
void this.calendarQueueService
.add<FetchOnboardingInviteSuggestionsJobData>(
FetchOnboardingInviteSuggestionsJob.name,
{
workspaceId,
userId,
connectedAccountId: newOrExistingConnectedAccountId,
},
)
.catch(() => undefined);
}
}
return newOrExistingConnectedAccountId;
@@ -42,7 +42,6 @@ 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';
@@ -67,7 +66,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
MessagingModule,
CalendarModule,
CalendarEventParticipantManagerModule,
OnboardingInviteSuggestionsModule,
TimelineActivityModule,
StripeModule,
FeatureFlagModule,
@@ -11,6 +11,7 @@ import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-co
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 { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
@@ -31,10 +32,12 @@ export class OnboardingResolver {
async getInviteSuggestions(
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<InviteSuggestionDTO[]> {
return this.onboardingInviteSuggestionsService.getCachedSuggestions({
return this.onboardingInviteSuggestionsService.getOrComputeSuggestions({
workspaceId: workspace.id,
userId: user.id,
userWorkspaceId,
});
}
@@ -129,43 +129,6 @@ 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,
@@ -1,26 +0,0 @@
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,
);
}
}
@@ -3,7 +3,6 @@ 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';
@@ -19,7 +18,6 @@ import { OnboardingInviteSuggestionsService } from 'src/modules/onboarding-invit
MicrosoftCalendarAttendeesService,
CalendarAttendeesService,
OnboardingInviteSuggestionsService,
FetchOnboardingInviteSuggestionsJob,
],
exports: [OnboardingInviteSuggestionsService],
})
@@ -9,20 +9,14 @@ import { CacheStorageService } from 'src/engine/core-modules/cache-storage/servi
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 { isGroupEmail } from 'src/modules/messaging/message-import-manager/utils/is-group-email';
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 { getOnboardingInviteSuggestionsCacheKey } from 'src/modules/onboarding-invite-suggestions/utils/get-onboarding-invite-suggestions-cache-key.util';
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);
@@ -35,54 +29,63 @@ export class OnboardingInviteSuggestionsService {
private readonly cacheStorageService: CacheStorageService,
) {}
async getCachedSuggestions({
async getOrComputeSuggestions({
workspaceId,
userId,
userWorkspaceId,
}: {
workspaceId: string;
userId: string;
userWorkspaceId: 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 },
});
const cachedSuggestions =
await this.cacheStorageService.get<CalendarAttendee[]>(cacheKey);
if (!isDefined(connectedAccount)) {
await this.cacheStorageService.set<CalendarAttendee[]>(
cacheKey,
[],
ONBOARDING_INVITE_SUGGESTIONS_CACHE_TTL_MS,
);
return;
if (isDefined(cachedSuggestions)) {
return cachedSuggestions;
}
const connectedAccountHandle = connectedAccount.handle.toLowerCase();
const suggestions = await this.computeSuggestionsFromConnectedAccount({
workspaceId,
userWorkspaceId,
});
await this.cacheStorageService.set<CalendarAttendee[]>(
cacheKey,
suggestions,
ONBOARDING_INVITE_SUGGESTIONS_CACHE_TTL_MS,
);
return suggestions;
}
private async computeSuggestionsFromConnectedAccount({
workspaceId,
userWorkspaceId,
}: {
workspaceId: string;
userWorkspaceId: string;
}): Promise<CalendarAttendee[]> {
const mostRecentlyConnectedAccount =
await this.connectedAccountRepository.findOne({
where: { userWorkspaceId, workspaceId },
order: { createdAt: 'DESC' },
});
if (!isDefined(mostRecentlyConnectedAccount)) {
return [];
}
const connectedAccountHandle =
mostRecentlyConnectedAccount.handle.toLowerCase();
if (!isWorkEmail(connectedAccountHandle)) {
await this.cacheStorageService.set<CalendarAttendee[]>(
cacheKey,
[],
ONBOARDING_INVITE_SUGGESTIONS_CACHE_TTL_MS,
);
return;
return [];
}
const connectedAccountDomain = getDomainNameFromHandle(
@@ -90,7 +93,7 @@ export class OnboardingInviteSuggestionsService {
);
const ownEmailHandles = new Set<string>([
connectedAccountHandle,
...(connectedAccount.handleAliases ?? []).map((alias) =>
...(mostRecentlyConnectedAccount.handleAliases ?? []).map((alias) =>
alias.toLowerCase(),
),
]);
@@ -98,10 +101,9 @@ export class OnboardingInviteSuggestionsService {
let attendees: CalendarAttendee[] = [];
try {
attendees =
await this.calendarAttendeesService.getRecentAttendees(
connectedAccount,
);
attendees = await this.calendarAttendeesService.getRecentAttendees(
mostRecentlyConnectedAccount,
);
} catch (error) {
this.logger.warn(
`Could not compute invite suggestions for workspace ${workspaceId}: ${
@@ -150,14 +152,8 @@ export class OnboardingInviteSuggestionsService {
eventCountByColleagueEmail.entries(),
).sort(([, left], [, right]) => right.eventCount - left.eventCount);
const suggestions: CalendarAttendee[] = mostFrequentColleaguesFirst
return 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,
);
}
}