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:
-26
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
-2
@@ -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],
|
||||
})
|
||||
|
||||
+48
-52
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user