From 59672b71b89687a8d1b46ccb40fd6360519eeb66 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Wed, 5 Aug 2026 13:34:06 +0200
Subject: [PATCH] Add a book-a-call onboarding step for qualified leads
(#23521)
https://github.com/user-attachments/assets/76d5a14e-53bd-4195-963b-bf9bb265c8c1
Large-company signups either self-serve a small plan or drop off at the
paywall without sales ever seeing them. This adds an embedded Cal.com
booking step to onboarding, shown only to leads worth a call.
The step sits between Invite Team and the plan step: the lead has built
out a workspace by then, and sales gets a chance before checkout. It is
always skippable, and a successful booking advances automatically.
Qualification reuses the employee count from the People Data Labs
enrichment added in #23199. `ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT`
sets the bar; leaving it unset means the step never appears.
`CALENDAR_BOOKING_PAGE_ID` must also be configured, so the step can
never strand someone on an empty embed.
Enrichment is no longer gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, since
the book-a-call step is now a second consumer of it.
`PEOPLE_DATA_LABS_API_KEY` remains the instance-level switch.
The existing `/book-call` page is reused: it moves into the onboarding
shell and its footer switches between Skip (as a step) and the back link
(when reached from the plan page).
---
.../src/metadata/generated/schema.graphql | 5 +
.../src/metadata/generated/schema.ts | 11 +-
.../src/metadata/generated/types.ts | 12 +
.../src/generated-metadata/graphql.ts | 15 +-
...sePageChangeEffectNavigateLocation.test.ts | 25 +
.../usePageChangeEffectNavigateLocation.ts | 7 +
.../app/hooks/useCreateWorkspaceAppRouter.tsx | 19 +-
.../client-config/hooks/useClientConfig.ts | 18 +
.../isBookCallOnboardingStepEnabledState.ts | 6 +
.../states/isCompanyEnrichmentEnabledState.ts | 6 +
.../client-config/types/ClientConfig.ts | 2 +
.../onboarding/components/BookCallEmbed.tsx | 44 ++
.../BookCallOnboardingStepActions.tsx | 39 ++
.../components/OnboardingStepLayout.tsx | 2 +
.../__tests__/BookCallEmbed.test.tsx | 101 ++++
.../BookCallOnboardingStepActions.test.tsx | 152 ++++++
.../CompanyEnrichmentSettlementTimeoutMs.ts | 1 +
.../OnboardingBookCallPendingUserVarKey.ts | 2 +
.../BookCallBookingSuccessEffect.tsx | 51 ++
.../CompanyEnrichmentOnboardingEffect.tsx | 67 ++-
.../PrefetchBookCallStepEffect.tsx | 7 +
.../BookCallBookingSuccessEffect.test.tsx | 68 +++
...CompanyEnrichmentOnboardingEffect.test.tsx | 199 +++++++-
.../PrefetchBookCallStepEffect.test.tsx | 103 ++++
.../completeBookCallOnboardingStep.ts | 9 +
.../mutations/enrichWorkspaceCompany.ts | 1 +
...useCompleteBookCallOnboardingStep.test.tsx | 135 +++++
.../hooks/__tests__/useInviteTeam.test.ts | 196 ++++++++
.../useSetNextOnboardingStatus.test.ts | 196 +++++++-
.../useCompleteBookCallOnboardingStep.ts | 39 ++
.../modules/onboarding/hooks/useInviteTeam.ts | 78 ++-
.../hooks/usePreloadCalForBookCallStep.ts | 48 ++
.../hooks/useSetNextOnboardingStatus.ts | 33 +-
...waitForCompanyEnrichmentSettlement.test.ts | 99 ++++
.../onboarding/utils/getAttendeeName.ts | 11 +
.../utils/getHasAdvancedPastBookCallStep.ts | 7 +
.../getIsBookCallOnboardingStepPending.ts | 7 +
.../setIsBookCallOnboardingStepPending.ts | 21 +
.../waitForCompanyEnrichmentSettlement.ts | 66 +++
.../src/pages/onboarding/BookCall.tsx | 99 ++--
.../src/pages/onboarding/InviteTeam.tsx | 2 +
.../onboarding/__tests__/BookCall.test.tsx | 90 ++++
.../src/testing/mock-data/config.ts | 2 +
packages/twenty-server/.env.example | 12 +-
.../client-config.controller.spec.ts | 2 +
.../client-config/client-config.entity.ts | 6 +
.../services/client-config.service.spec.ts | 3 +
.../services/client-config.service.ts | 10 +
.../company-enrichment.module.ts | 2 +
...workspace-company-enrichment-result.dto.ts | 3 +
.../company-enrichment.resolver.spec.ts | 108 ++++
.../resolvers/company-enrichment.resolver.ts | 38 +-
.../company-enrichment.service.spec.ts | 114 ++++-
.../services/company-enrichment.service.ts | 14 +-
...is-company-enrichment-enabled.util.spec.ts | 71 +++
...read-is-company-enrichment-enabled.util.ts | 20 +
.../key-value-pair/key-value-pair.service.ts | 43 ++
.../enums/onboarding-status.enum.ts | 1 +
.../onboarding/onboarding.resolver.ts | 15 +
.../onboarding/onboarding.service.spec.ts | 475 +++++++++++++++++-
.../onboarding/onboarding.service.ts | 153 +++++-
...-call-step-min-employee-count.util.spec.ts | 62 +++
...-book-call-step-min-employee-count.util.ts | 19 +
...-book-call-step-min-employee-count.util.ts | 12 +
.../twenty-config/config-variables.ts | 12 +
.../user-vars/services/user-vars.service.ts | 26 +
.../engine/core-modules/user/user.resolver.ts | 1 +
67 files changed, 3157 insertions(+), 166 deletions(-)
create mode 100644 packages/twenty-front/src/modules/client-config/states/isBookCallOnboardingStepEnabledState.ts
create mode 100644 packages/twenty-front/src/modules/client-config/states/isCompanyEnrichmentEnabledState.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/components/BookCallEmbed.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/components/BookCallOnboardingStepActions.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/components/__tests__/BookCallEmbed.test.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/components/__tests__/BookCallOnboardingStepActions.test.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/constants/CompanyEnrichmentSettlementTimeoutMs.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/constants/OnboardingBookCallPendingUserVarKey.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/effect-components/BookCallBookingSuccessEffect.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/effect-components/PrefetchBookCallStepEffect.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/effect-components/__tests__/BookCallBookingSuccessEffect.test.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/effect-components/__tests__/PrefetchBookCallStepEffect.test.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/graphql/mutations/completeBookCallOnboardingStep.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/__tests__/useCompleteBookCallOnboardingStep.test.tsx
create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/__tests__/useInviteTeam.test.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/useCompleteBookCallOnboardingStep.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/usePreloadCalForBookCallStep.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/utils/__tests__/waitForCompanyEnrichmentSettlement.test.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/utils/getAttendeeName.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/utils/getHasAdvancedPastBookCallStep.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/utils/getIsBookCallOnboardingStepPending.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/utils/setIsBookCallOnboardingStepPending.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/utils/waitForCompanyEnrichmentSettlement.ts
create mode 100644 packages/twenty-front/src/pages/onboarding/__tests__/BookCall.test.tsx
create mode 100644 packages/twenty-server/src/engine/core-modules/company-enrichment/resolvers/__tests__/company-enrichment.resolver.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/company-enrichment/utils/__tests__/read-is-company-enrichment-enabled.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/onboarding/utils/__tests__/get-book-call-step-min-employee-count.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/onboarding/utils/get-book-call-step-min-employee-count.util.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util.ts
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
index 809184eb59..d6db3499e3 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
@@ -969,6 +969,7 @@ enum OnboardingStatus {
SYNC_EMAIL
APPS_INSTALLATION
INVITE_TEAM
+ BOOK_CALL
COMPLETED
}
@@ -1994,6 +1995,8 @@ type ClientConfig {
isEmailingDomainInDemoMode: Boolean!
allowRequestsToTwentyIcons: Boolean!
calendarBookingPageId: String
+ isBookCallOnboardingStepEnabled: Boolean!
+ isCompanyEnrichmentEnabled: Boolean!
isCloudflareIntegrationEnabled: Boolean!
isClickHouseConfigured: Boolean!
isWorkspaceSchemaDDLLocked: Boolean!
@@ -2190,6 +2193,7 @@ type MarketplaceAppDetail {
type WorkspaceCompanyEnrichmentResult {
outcome: WorkspaceCompanyEnrichmentOutcome!
enrichment: JSON
+ isBookCallOnboardingStepPending: Boolean!
}
enum WorkspaceCompanyEnrichmentOutcome {
@@ -3485,6 +3489,7 @@ type Mutation {
revokeUserSession(userSessionId: UUID!): Boolean!
revokeAllOtherUserSessions: Int!
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
+ completeBookCallOnboardingStep: OnboardingStepSuccess!
triggerInstallAppsOnboardingStep(universalIdentifiers: [String!]!): OnboardingStepSuccess!
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
index bf6a8c689d..f24e854b13 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
@@ -687,7 +687,7 @@ export interface User {
/** Onboarding status */
-export type OnboardingStatus = 'PLAN_REQUIRED' | 'WORKSPACE_ACTIVATION' | 'PROFILE_CREATION' | 'SYNC_EMAIL' | 'APPS_INSTALLATION' | 'INVITE_TEAM' | 'COMPLETED'
+export type OnboardingStatus = 'PLAN_REQUIRED' | 'WORKSPACE_ACTIVATION' | 'PROFILE_CREATION' | 'SYNC_EMAIL' | 'APPS_INSTALLATION' | 'INVITE_TEAM' | 'BOOK_CALL' | 'COMPLETED'
export interface ApplicationRegistration {
id: Scalars['UUID']
@@ -1634,6 +1634,8 @@ export interface ClientConfig {
isEmailingDomainInDemoMode: Scalars['Boolean']
allowRequestsToTwentyIcons: Scalars['Boolean']
calendarBookingPageId?: Scalars['String']
+ isBookCallOnboardingStepEnabled: Scalars['Boolean']
+ isCompanyEnrichmentEnabled: Scalars['Boolean']
isCloudflareIntegrationEnabled: Scalars['Boolean']
isClickHouseConfigured: Scalars['Boolean']
isWorkspaceSchemaDDLLocked: Scalars['Boolean']
@@ -1854,6 +1856,7 @@ export interface MarketplaceAppDetail {
export interface WorkspaceCompanyEnrichmentResult {
outcome: WorkspaceCompanyEnrichmentOutcome
enrichment?: Scalars['JSON']
+ isBookCallOnboardingStepPending: Scalars['Boolean']
__typename: 'WorkspaceCompanyEnrichmentResult'
}
@@ -2999,6 +3002,7 @@ export interface Mutation {
revokeUserSession: Scalars['Boolean']
revokeAllOtherUserSessions: Scalars['Int']
skipSyncEmailOnboardingStep: OnboardingStepSuccess
+ completeBookCallOnboardingStep: OnboardingStepSuccess
triggerInstallAppsOnboardingStep: OnboardingStepSuccess
updateOneApplicationVariable: Scalars['Boolean']
checkoutSession: BillingSession
@@ -4882,6 +4886,8 @@ export interface ClientConfigGenqlSelection{
isEmailingDomainInDemoMode?: boolean | number
allowRequestsToTwentyIcons?: boolean | number
calendarBookingPageId?: boolean | number
+ isBookCallOnboardingStepEnabled?: boolean | number
+ isCompanyEnrichmentEnabled?: boolean | number
isCloudflareIntegrationEnabled?: boolean | number
isClickHouseConfigured?: boolean | number
isWorkspaceSchemaDDLLocked?: boolean | number
@@ -5124,6 +5130,7 @@ export interface MarketplaceAppDetailGenqlSelection{
export interface WorkspaceCompanyEnrichmentResultGenqlSelection{
outcome?: boolean | number
enrichment?: boolean | number
+ isBookCallOnboardingStepPending?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -6370,6 +6377,7 @@ export interface MutationGenqlSelection{
revokeUserSession?: { __args: {userSessionId: Scalars['UUID']} }
revokeAllOtherUserSessions?: boolean | number
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
+ completeBookCallOnboardingStep?: OnboardingStepSuccessGenqlSelection
triggerInstallAppsOnboardingStep?: (OnboardingStepSuccessGenqlSelection & { __args: {universalIdentifiers: Scalars['String'][]} })
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)} })
@@ -9432,6 +9440,7 @@ export const enumOnboardingStatus = {
SYNC_EMAIL: 'SYNC_EMAIL' as const,
APPS_INSTALLATION: 'APPS_INSTALLATION' as const,
INVITE_TEAM: 'INVITE_TEAM' as const,
+ BOOK_CALL: 'BOOK_CALL' as const,
COMPLETED: 'COMPLETED' as const
}
diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts
index 2e6591f4bf..e8778533e3 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/types.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts
@@ -3858,6 +3858,12 @@ export default {
"calendarBookingPageId": [
1
],
+ "isBookCallOnboardingStepEnabled": [
+ 3
+ ],
+ "isCompanyEnrichmentEnabled": [
+ 3
+ ],
"isCloudflareIntegrationEnabled": [
3
],
@@ -4359,6 +4365,9 @@ export default {
"enrichment": [
5
],
+ "isBookCallOnboardingStepPending": [
+ 3
+ ],
"__typename": [
1
]
@@ -7872,6 +7881,9 @@ export default {
"skipSyncEmailOnboardingStep": [
170
],
+ "completeBookCallOnboardingStep": [
+ 170
+ ],
"triggerInstallAppsOnboardingStep": [
170,
{
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 272cc5fc39..f17dd32432 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -962,8 +962,10 @@ export type ClientConfig = {
enterpriseInstanceType: Scalars['String']['output'];
frontDomain: Scalars['String']['output'];
isAttachmentPreviewEnabled: Scalars['Boolean']['output'];
+ isBookCallOnboardingStepEnabled: Scalars['Boolean']['output'];
isClickHouseConfigured: Scalars['Boolean']['output'];
isCloudflareIntegrationEnabled: Scalars['Boolean']['output'];
+ isCompanyEnrichmentEnabled: Scalars['Boolean']['output'];
isConfigVariablesInDbEnabled: Scalars['Boolean']['output'];
isCookieSessionEnabled: Scalars['Boolean']['output'];
isEmailVerificationRequired: Scalars['Boolean']['output'];
@@ -2610,6 +2612,7 @@ export type Mutation = {
checkPublicDomainValidRecords?: Maybe;
checkoutSession: BillingSession;
claimApplicationRegistrationOwnership: ApplicationRegistration;
+ completeBookCallOnboardingStep: OnboardingStepSuccess;
completeFileUpload: FileWithSignedUrl;
createApiKey: ApiKey;
createApplicationRegistration: CreateApplicationRegistration;
@@ -4236,6 +4239,7 @@ export enum ObjectRecordGroupByDateGranularity {
/** Onboarding status */
export enum OnboardingStatus {
APPS_INSTALLATION = 'APPS_INSTALLATION',
+ BOOK_CALL = 'BOOK_CALL',
COMPLETED = 'COMPLETED',
INVITE_TEAM = 'INVITE_TEAM',
PLAN_REQUIRED = 'PLAN_REQUIRED',
@@ -6640,6 +6644,7 @@ export enum WorkspaceCompanyEnrichmentOutcome {
export type WorkspaceCompanyEnrichmentResult = {
__typename?: 'WorkspaceCompanyEnrichmentResult';
enrichment?: Maybe;
+ isBookCallOnboardingStepPending: Scalars['Boolean']['output'];
outcome: WorkspaceCompanyEnrichmentOutcome;
};
@@ -7776,10 +7781,15 @@ export type MostlyEmptyFieldMetadataIdsQueryVariables = Exact<{
export type MostlyEmptyFieldMetadataIdsQuery = { __typename?: 'Query', mostlyEmptyFieldMetadataIds: Array };
+export type CompleteBookCallOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
+
+
+export type CompleteBookCallOnboardingStepMutation = { __typename?: 'Mutation', completeBookCallOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } };
+
export type EnrichWorkspaceCompanyMutationVariables = Exact<{ [key: string]: never; }>;
-export type EnrichWorkspaceCompanyMutation = { __typename?: 'Mutation', enrichWorkspaceCompany: { __typename?: 'WorkspaceCompanyEnrichmentResult', outcome: WorkspaceCompanyEnrichmentOutcome, enrichment?: any | null } };
+export type EnrichWorkspaceCompanyMutation = { __typename?: 'Mutation', enrichWorkspaceCompany: { __typename?: 'WorkspaceCompanyEnrichmentResult', outcome: WorkspaceCompanyEnrichmentOutcome, enrichment?: any | null, isBookCallOnboardingStepPending: boolean } };
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
@@ -9464,7 +9474,8 @@ export const DeleteOneIndexMetadataItemDocument = {"kind":"Document","definition
export const ObjectMetadataItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ObjectMetadataItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ObjectMetadataFields"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ObjectMetadataFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Object"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}},{"kind":"Field","name":{"kind":"Name","value":"labelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"labelPlural"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isRemote"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"isUIEditable"}},{"kind":"Field","name":{"kind":"Name","value":"isUICreatable"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"shortcut"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"isSearchable"}},{"kind":"Field","name":{"kind":"Name","value":"openRecordIn"}},{"kind":"Field","name":{"kind":"Name","value":"duplicateCriteria"}},{"kind":"Field","name":{"kind":"Name","value":"searchFieldMetadataList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"tsVectorFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"indexMetadataList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"indexWhereClause"}},{"kind":"Field","name":{"kind":"Name","value":"indexType"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"indexFieldMetadataList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"order"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"fieldsList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"isUIEditable"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"defaultValue"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"morphId"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"relation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"sourceObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sourceFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"morphRelations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"sourceObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sourceFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode;
export const ObjectRecordCountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ObjectRecordCounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectRecordCounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectNamePlural"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode;
export const MostlyEmptyFieldMetadataIdsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MostlyEmptyFieldMetadataIds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectMetadataId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"mostlyEmptyFieldMetadataIds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"objectMetadataId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectMetadataId"}}}]}]}}]} as unknown as DocumentNode;
-export const EnrichWorkspaceCompanyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EnrichWorkspaceCompany"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enrichWorkspaceCompany"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"outcome"}},{"kind":"Field","name":{"kind":"Name","value":"enrichment"}}]}}]}}]} as unknown as DocumentNode;
+export const CompleteBookCallOnboardingStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CompleteBookCallOnboardingStep"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"completeBookCallOnboardingStep"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode;
+export const EnrichWorkspaceCompanyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EnrichWorkspaceCompany"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enrichWorkspaceCompany"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"outcome"}},{"kind":"Field","name":{"kind":"Name","value":"enrichment"}},{"kind":"Field","name":{"kind":"Name","value":"isBookCallOnboardingStepPending"}}]}}]}}]} as unknown as DocumentNode;
export const SkipSyncEmailOnboardingStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SkipSyncEmailOnboardingStep"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"skipSyncEmailOnboardingStep"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode;
export const StartWorkspaceSetupChatDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StartWorkspaceSetupChat"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"companyContext"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startWorkspaceSetupChat"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"companyContext"},"value":{"kind":"Variable","name":{"kind":"Name","value":"companyContext"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"outcome"}},{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode;
export const TriggerInstallAppsOnboardingStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TriggerInstallAppsOnboardingStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifiers"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"triggerInstallAppsOnboardingStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"universalIdentifiers"},"value":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifiers"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode;
diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
index 6d94da2605..2b206e12c5 100644
--- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
+++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
@@ -128,6 +128,7 @@ const testCases: {
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -138,6 +139,7 @@ const testCases: {
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -148,6 +150,7 @@ const testCases: {
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' },
@@ -158,6 +161,7 @@ const testCases: {
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
+ { loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' },
@@ -168,6 +172,7 @@ const testCases: {
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
+ { loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -180,6 +185,7 @@ const testCases: {
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -190,6 +196,7 @@ const testCases: {
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -200,6 +207,7 @@ const testCases: {
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -210,6 +218,7 @@ const testCases: {
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: undefined },
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -220,6 +229,7 @@ const testCases: {
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: undefined },
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -230,6 +240,7 @@ const testCases: {
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: undefined },
+ { loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.PlanRequired },
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
@@ -240,6 +251,7 @@ const testCases: {
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: undefined },
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
@@ -250,6 +262,7 @@ const testCases: {
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
@@ -260,6 +273,7 @@ const testCases: {
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -270,6 +284,7 @@ const testCases: {
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -280,6 +295,7 @@ const testCases: {
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -290,6 +306,7 @@ const testCases: {
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -300,6 +317,7 @@ const testCases: {
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, objectNamePluralFromParams: 'existing-object', objectNamePluralFromMetadata: 'existing-object' },
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, objectNamePluralFromParams: 'non-existing-object', objectNamePluralFromMetadata: 'existing-object' },
@@ -313,6 +331,7 @@ const testCases: {
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -323,6 +342,7 @@ const testCases: {
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, pageLayoutId: 'valid-id', useQueryResult: { loading: true } },
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, pageLayoutId: 'non-existent-id', useQueryResult: { data: { getPageLayout: null }, loading: false } },
@@ -337,6 +357,7 @@ const testCases: {
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -347,6 +368,7 @@ const testCases: {
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -357,6 +379,7 @@ const testCases: {
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -367,6 +390,7 @@ const testCases: {
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
@@ -377,6 +401,7 @@ const testCases: {
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
+ { loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
// isBillingEnabled:false — no post-invite-team upgrade interception on billing-disabled instances
diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
index 3112fc0bff..6100788b70 100644
--- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
+++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
@@ -164,6 +164,13 @@ export const usePageChangeEffectNavigateLocation = () => {
return AppPath.InviteTeam;
}
+ if (
+ onboardingStatus === OnboardingStatus.BOOK_CALL &&
+ !isMatchingLocation(location, AppPath.BookCall)
+ ) {
+ return AppPath.BookCall;
+ }
+
if (isBillingEnabled && onboardingStatus === OnboardingStatus.COMPLETED) {
if (isMatchingLocation(location, AppPath.InviteTeam)) {
return AppPath.PlanRequired;
diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx
index 8e206da6c1..f4b27fc450 100644
--- a/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx
+++ b/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx
@@ -99,7 +99,7 @@ const PaymentSuccess = lazy(() =>
})),
);
-const BookCall = lazy(() =>
+const BookCall = lazyWithPreload(() =>
import('~/pages/onboarding/BookCall').then((module) => ({
default: module.BookCall,
})),
@@ -129,6 +129,7 @@ const preloadOnboardingPages = () => {
SyncEmails.preload();
InstallApps.preload();
InviteTeam.preload();
+ BookCall.preload();
ChooseYourPlan.preload();
WorkspaceSetup.preload();
@@ -231,14 +232,6 @@ const createWorkspaceAppRouter = (
}
/>
- }>
-
-
- }
- />
}>
}
/>
+ }>
+
+
+ }
+ />
{
const setCalendarBookingPageId = useSetAtomState(calendarBookingPageIdState);
+ const setIsBookCallOnboardingStepEnabled = useSetAtomState(
+ isBookCallOnboardingStepEnabledState,
+ );
+
+ const setIsCompanyEnrichmentEnabled = useSetAtomState(
+ isCompanyEnrichmentEnabledState,
+ );
+
const setIsEmailingDomainInDemoMode = useSetAtomState(
isEmailingDomainInDemoModeState,
);
@@ -215,6 +225,12 @@ export const useClientConfig = (): UseClientConfigResult => {
}));
setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null);
+ setIsBookCallOnboardingStepEnabled(
+ clientConfig?.isBookCallOnboardingStepEnabled ?? false,
+ );
+ setIsCompanyEnrichmentEnabled(
+ clientConfig?.isCompanyEnrichmentEnabled ?? false,
+ );
setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled);
setIsEmailingDomainInDemoMode(
clientConfig?.isEmailingDomainInDemoMode ?? false,
@@ -260,6 +276,8 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsGoogleMessagingEnabled,
setIsAnalyticsEnabled,
setIsAttachmentPreviewEnabled,
+ setIsBookCallOnboardingStepEnabled,
+ setIsCompanyEnrichmentEnabled,
setIsConfigVariablesInDbEnabled,
setIsDeveloperDefaultSignInPrefilled,
setIsEmailVerificationRequired,
diff --git a/packages/twenty-front/src/modules/client-config/states/isBookCallOnboardingStepEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isBookCallOnboardingStepEnabledState.ts
new file mode 100644
index 0000000000..6806056491
--- /dev/null
+++ b/packages/twenty-front/src/modules/client-config/states/isBookCallOnboardingStepEnabledState.ts
@@ -0,0 +1,6 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const isBookCallOnboardingStepEnabledState = createAtomState({
+ key: 'isBookCallOnboardingStepEnabledState',
+ defaultValue: false,
+});
diff --git a/packages/twenty-front/src/modules/client-config/states/isCompanyEnrichmentEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isCompanyEnrichmentEnabledState.ts
new file mode 100644
index 0000000000..cc135c862e
--- /dev/null
+++ b/packages/twenty-front/src/modules/client-config/states/isCompanyEnrichmentEnabledState.ts
@@ -0,0 +1,6 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const isCompanyEnrichmentEnabledState = createAtomState({
+ key: 'isCompanyEnrichmentEnabledState',
+ defaultValue: false,
+});
diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
index c7dc6eb7da..e1ec32e44c 100644
--- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
+++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
@@ -20,6 +20,8 @@ export type ClientConfig = {
authProviders: AuthProviders;
billing: Billing;
calendarBookingPageId?: string;
+ isBookCallOnboardingStepEnabled: boolean;
+ isCompanyEnrichmentEnabled: boolean;
canManageFeatureFlags: boolean;
captcha: Captcha;
defaultSubdomain?: string;
diff --git a/packages/twenty-front/src/modules/onboarding/components/BookCallEmbed.tsx b/packages/twenty-front/src/modules/onboarding/components/BookCallEmbed.tsx
new file mode 100644
index 0000000000..7ebaf3c004
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/components/BookCallEmbed.tsx
@@ -0,0 +1,44 @@
+import Cal from '@calcom/embed-react';
+import { isDefined } from 'twenty-shared/utils';
+
+import { currentUserState } from '@/auth/states/currentUserState';
+import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
+import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { getAttendeeName } from '@/onboarding/utils/getAttendeeName';
+import { useThemeColorScheme } from 'twenty-ui/theme-constants';
+import { useIsMobile } from 'twenty-ui/utilities';
+
+type BookCallEmbedProps = {
+ calendarBookingPageId: string;
+};
+
+export const BookCallEmbed = ({
+ calendarBookingPageId,
+}: BookCallEmbedProps) => {
+ const colorScheme = useThemeColorScheme();
+ const currentUser = useAtomStateValue(currentUserState);
+ const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
+ const isMobile = useIsMobile();
+
+ const attendeeName = isDefined(currentWorkspaceMember?.name)
+ ? getAttendeeName(currentWorkspaceMember.name)
+ : getAttendeeName(currentUser);
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/onboarding/components/BookCallOnboardingStepActions.tsx b/packages/twenty-front/src/modules/onboarding/components/BookCallOnboardingStepActions.tsx
new file mode 100644
index 0000000000..2268fdedb4
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/components/BookCallOnboardingStepActions.tsx
@@ -0,0 +1,39 @@
+import { CombinedGraphQLErrors } from '@apollo/client/errors';
+import { useCallback, useState } from 'react';
+
+import { OnboardingSkipButton } from '@/onboarding/components/OnboardingSkipButton';
+import { BookCallBookingSuccessEffect } from '@/onboarding/effect-components/BookCallBookingSuccessEffect';
+import { useCompleteBookCallOnboardingStep } from '@/onboarding/hooks/useCompleteBookCallOnboardingStep';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+
+export const BookCallOnboardingStepActions = () => {
+ const { enqueueErrorSnackBar } = useSnackBar();
+ const completeBookCallOnboardingStep = useCompleteBookCallOnboardingStep();
+ const [isCompleting, setIsCompleting] = useState(false);
+
+ // Kept stable so BookCallBookingSuccessEffect subscribes to the embed once
+ // instead of cycling its listener on every render.
+ const completeStep = useCallback(async () => {
+ setIsCompleting(true);
+
+ try {
+ await completeBookCallOnboardingStep();
+ } catch (error) {
+ setIsCompleting(false);
+
+ enqueueErrorSnackBar({
+ apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
+ });
+ }
+ }, [completeBookCallOnboardingStep, enqueueErrorSnackBar]);
+
+ return (
+ <>
+
+ void completeStep()}
+ disabled={isCompleting}
+ />
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx b/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx
index de7e9bbb25..465e91f40e 100644
--- a/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx
+++ b/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx
@@ -1,6 +1,7 @@
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
import { OnboardingLayout } from '@/onboarding/components/OnboardingLayout';
import { OnboardingTransitionOutlet } from '@/onboarding/components/OnboardingTransitionOutlet';
+import { PrefetchBookCallStepEffect } from '@/onboarding/effect-components/PrefetchBookCallStepEffect';
import { PrefetchPlanRequiredStepEffect } from '@/onboarding/effect-components/PrefetchPlanRequiredStepEffect';
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -14,6 +15,7 @@ export const OnboardingStepLayout = () => {
+
diff --git a/packages/twenty-front/src/modules/onboarding/components/__tests__/BookCallEmbed.test.tsx b/packages/twenty-front/src/modules/onboarding/components/__tests__/BookCallEmbed.test.tsx
new file mode 100644
index 0000000000..89e943b86e
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/components/__tests__/BookCallEmbed.test.tsx
@@ -0,0 +1,101 @@
+import { render } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+
+import { currentUserState } from '@/auth/states/currentUserState';
+import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
+import { BookCallEmbed } from '@/onboarding/components/BookCallEmbed';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
+
+const mockCalConfig = jest.fn();
+
+jest.mock('@calcom/embed-react', () => ({
+ __esModule: true,
+ default: (props: { config?: unknown }) => {
+ mockCalConfig(props.config);
+
+ return null;
+ },
+}));
+
+jest.mock('@/ui/utilities/scroll/components/ScrollWrapper', () => ({
+ ScrollWrapper: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+const renderEmbed = ({
+ userName,
+ workspaceMemberName,
+}: {
+ userName?: { firstName: string; lastName: string };
+ workspaceMemberName?: { firstName: string; lastName: string };
+}) => {
+ jotaiStore.set(currentUserState.atom, {
+ id: 'user-id',
+ email: 'raphael@acme.com',
+ firstName: userName?.firstName ?? '',
+ lastName: userName?.lastName ?? '',
+ } as never);
+
+ if (workspaceMemberName) {
+ jotaiStore.set(currentWorkspaceMemberState.atom, {
+ id: 'workspace-member-id',
+ name: workspaceMemberName,
+ } as never);
+ }
+
+ render(
+
+
+ ,
+ );
+
+ return mockCalConfig.mock.calls.at(-1)?.[0] as { name: string };
+};
+
+describe('BookCallEmbed', () => {
+ beforeEach(() => {
+ resetJotaiStore();
+ jest.clearAllMocks();
+ });
+
+ it('should prefill the name entered at the profile step', () => {
+ const config = renderEmbed({
+ workspaceMemberName: { firstName: 'Raphael', lastName: 'Bosi' },
+ });
+
+ expect(config.name).toBe('Raphael Bosi');
+ });
+
+ it('should fall back to the sign-up name when there is no workspace member yet', () => {
+ const config = renderEmbed({
+ userName: { firstName: 'Raphael', lastName: 'Bosi' },
+ });
+
+ expect(config.name).toBe('Raphael Bosi');
+ });
+
+ it('should not emit a stray separator when only one name part is set', () => {
+ const config = renderEmbed({
+ workspaceMemberName: { firstName: 'Raphael', lastName: '' },
+ });
+
+ expect(config.name).toBe('Raphael');
+ });
+
+ it('should not mix the workspace member name with the sign-up name', () => {
+ const config = renderEmbed({
+ userName: { firstName: 'Raphael', lastName: 'Bosi' },
+ workspaceMemberName: { firstName: 'Raph', lastName: '' },
+ });
+
+ expect(config.name).toBe('Raph');
+ });
+
+ it('should send an empty name rather than a blank string when nothing is known', () => {
+ const config = renderEmbed({});
+
+ expect(config.name).toBe('');
+ });
+});
diff --git a/packages/twenty-front/src/modules/onboarding/components/__tests__/BookCallOnboardingStepActions.test.tsx b/packages/twenty-front/src/modules/onboarding/components/__tests__/BookCallOnboardingStepActions.test.tsx
new file mode 100644
index 0000000000..d8ff2ce486
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/components/__tests__/BookCallOnboardingStepActions.test.tsx
@@ -0,0 +1,152 @@
+import { i18n } from '@lingui/core';
+import { I18nProvider } from '@lingui/react';
+import { act, render, screen } from '@testing-library/react';
+import { SOURCE_LOCALE } from 'twenty-shared/translations';
+import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
+
+import { BookCallOnboardingStepActions } from '@/onboarding/components/BookCallOnboardingStepActions';
+
+const mockCompleteBookCallOnboardingStep = jest.fn();
+const mockEnqueueErrorSnackBar = jest.fn();
+const mockCalApi = jest.fn();
+
+jest.mock('@calcom/embed-react', () => ({
+ getCalApi: () => Promise.resolve(mockCalApi),
+}));
+
+jest.mock('@/onboarding/hooks/useCompleteBookCallOnboardingStep', () => ({
+ useCompleteBookCallOnboardingStep: () => mockCompleteBookCallOnboardingStep,
+}));
+
+jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
+ useSnackBar: () => ({ enqueueErrorSnackBar: mockEnqueueErrorSnackBar }),
+}));
+
+dynamicActivate(SOURCE_LOCALE);
+
+const getSubscriptionCalls = () =>
+ mockCalApi.mock.calls.filter(([action]) => action === 'on');
+
+const emitBookingSuccessful = () => {
+ const [, subscription] = getSubscriptionCalls().at(-1) ?? [];
+
+ (subscription as { callback: () => void }).callback();
+};
+
+const renderActions = async () => {
+ const view = render(
+
+
+ ,
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ return { view, skipButton: screen.getByRole('button') };
+};
+
+describe('BookCallOnboardingStepActions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockCompleteBookCallOnboardingStep.mockResolvedValue(undefined);
+ });
+
+ it('should complete the step when a booking succeeds', async () => {
+ await renderActions();
+
+ await act(async () => {
+ emitBookingSuccessful();
+ });
+
+ expect(mockCompleteBookCallOnboardingStep).toHaveBeenCalledTimes(1);
+ });
+
+ it('should complete the step when skipping', async () => {
+ const { skipButton } = await renderActions();
+
+ await act(async () => {
+ skipButton.click();
+ });
+
+ expect(mockCompleteBookCallOnboardingStep).toHaveBeenCalledTimes(1);
+ });
+
+ it('should disable skipping while the booking completion is in flight', async () => {
+ let resolveCompletion: () => void = () => {};
+
+ mockCompleteBookCallOnboardingStep.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCompletion = resolve;
+ }),
+ );
+
+ const { skipButton } = await renderActions();
+
+ expect(skipButton).not.toBeDisabled();
+
+ act(() => {
+ emitBookingSuccessful();
+ });
+
+ expect(skipButton).toBeDisabled();
+
+ await act(async () => {
+ resolveCompletion();
+ });
+ });
+
+ it('should re-enable skipping and report the failure when the completion fails', async () => {
+ mockCompleteBookCallOnboardingStep.mockRejectedValueOnce(
+ new Error('network error'),
+ );
+
+ const { skipButton } = await renderActions();
+
+ await act(async () => {
+ emitBookingSuccessful();
+ });
+
+ expect(skipButton).not.toBeDisabled();
+ expect(mockEnqueueErrorSnackBar).toHaveBeenCalled();
+ });
+
+ it('should complete the step once even when the embed emits repeatedly', async () => {
+ mockCompleteBookCallOnboardingStep.mockRejectedValue(
+ new Error('network error'),
+ );
+
+ await renderActions();
+
+ await act(async () => {
+ emitBookingSuccessful();
+ });
+ await act(async () => {
+ emitBookingSuccessful();
+ });
+
+ expect(mockCompleteBookCallOnboardingStep).toHaveBeenCalledTimes(1);
+ });
+
+ it('should subscribe to the embed once across re-renders', async () => {
+ const { view } = await renderActions();
+
+ await act(async () => {
+ emitBookingSuccessful();
+ });
+
+ view.rerender(
+
+
+ ,
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(getSubscriptionCalls()).toHaveLength(1);
+ expect(mockCalApi).not.toHaveBeenCalledWith('off', expect.anything());
+ });
+});
diff --git a/packages/twenty-front/src/modules/onboarding/constants/CompanyEnrichmentSettlementTimeoutMs.ts b/packages/twenty-front/src/modules/onboarding/constants/CompanyEnrichmentSettlementTimeoutMs.ts
new file mode 100644
index 0000000000..b824cc8b87
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/constants/CompanyEnrichmentSettlementTimeoutMs.ts
@@ -0,0 +1 @@
+export const COMPANY_ENRICHMENT_SETTLEMENT_TIMEOUT_MS = 2500;
diff --git a/packages/twenty-front/src/modules/onboarding/constants/OnboardingBookCallPendingUserVarKey.ts b/packages/twenty-front/src/modules/onboarding/constants/OnboardingBookCallPendingUserVarKey.ts
new file mode 100644
index 0000000000..25a070342e
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/constants/OnboardingBookCallPendingUserVarKey.ts
@@ -0,0 +1,2 @@
+export const ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY =
+ 'ONBOARDING_BOOK_CALL_PENDING';
diff --git a/packages/twenty-front/src/modules/onboarding/effect-components/BookCallBookingSuccessEffect.tsx b/packages/twenty-front/src/modules/onboarding/effect-components/BookCallBookingSuccessEffect.tsx
new file mode 100644
index 0000000000..37b0a25ae0
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/effect-components/BookCallBookingSuccessEffect.tsx
@@ -0,0 +1,51 @@
+import { getCalApi } from '@calcom/embed-react';
+import { useEffect } from 'react';
+
+type BookCallBookingSuccessEffectProps = {
+ onBookingSuccessful: () => void;
+};
+
+export const BookCallBookingSuccessEffect = ({
+ onBookingSuccessful,
+}: BookCallBookingSuccessEffectProps) => {
+ useEffect(() => {
+ let isSubscribed = true;
+ let hasHandledBookingSuccess = false;
+ let calApi: Awaited> | undefined;
+
+ const handleBookingSuccessful = () => {
+ if (hasHandledBookingSuccess) {
+ return;
+ }
+
+ hasHandledBookingSuccess = true;
+ onBookingSuccessful();
+ };
+
+ const subscribeToBookingSuccess = async () => {
+ const api = await getCalApi();
+
+ if (!isSubscribed) {
+ return;
+ }
+
+ calApi = api;
+ api('on', {
+ action: 'bookingSuccessfulV2',
+ callback: handleBookingSuccessful,
+ });
+ };
+
+ void subscribeToBookingSuccess();
+
+ return () => {
+ isSubscribed = false;
+ calApi?.('off', {
+ action: 'bookingSuccessfulV2',
+ callback: handleBookingSuccessful,
+ });
+ };
+ }, [onBookingSuccessful]);
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/onboarding/effect-components/CompanyEnrichmentOnboardingEffect.tsx b/packages/twenty-front/src/modules/onboarding/effect-components/CompanyEnrichmentOnboardingEffect.tsx
index 1fe1ddf0b1..01a9a3dd97 100644
--- a/packages/twenty-front/src/modules/onboarding/effect-components/CompanyEnrichmentOnboardingEffect.tsx
+++ b/packages/twenty-front/src/modules/onboarding/effect-components/CompanyEnrichmentOnboardingEffect.tsx
@@ -1,17 +1,22 @@
import { useMutation } from '@apollo/client/react';
+import { useStore } from 'jotai';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
-import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
+import { currentUserState } from '@/auth/states/currentUserState';
+import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
+import { getHasAdvancedPastBookCallStep } from '@/onboarding/utils/getHasAdvancedPastBookCallStep';
+import { setIsBookCallOnboardingStepPending } from '@/onboarding/utils/setIsBookCallOnboardingStepPending';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import {
+ CompleteBookCallOnboardingStepDocument,
EnrichWorkspaceCompanyDocument,
OnboardingStatus,
WorkspaceCompanyEnrichmentOutcome,
@@ -27,12 +32,17 @@ export const CompanyEnrichmentOnboardingEffect = () => {
setHasAttemptedCompanyEnrichmentFetch,
] = useAtomState(hasAttemptedCompanyEnrichmentFetchState);
const [enrichWorkspaceCompany] = useMutation(EnrichWorkspaceCompanyDocument);
+ const [completeBookCallOnboardingStep] = useMutation(
+ CompleteBookCallOnboardingStepDocument,
+ );
const setIsCompanyEnrichmentFetchInFlight = useSetAtomState(
isCompanyEnrichmentFetchInFlightState,
);
- const isOnboardingAiChatEnabled = useAtomStateValue(
- isOnboardingAiChatEnabledState,
+ const setCurrentUser = useSetAtomState(currentUserState);
+ const isCompanyEnrichmentEnabled = useAtomStateValue(
+ isCompanyEnrichmentEnabledState,
);
+ const store = useStore();
const isOnboardingInProgress =
isDefined(onboardingStatus) &&
@@ -44,31 +54,61 @@ export const CompanyEnrichmentOnboardingEffect = () => {
hasAttemptedCompanyEnrichmentFetch ||
isDefined(companyEnrichment) ||
!isOnboardingInProgress ||
- !isOnboardingAiChatEnabled
+ !isCompanyEnrichmentEnabled
) {
return;
}
- setHasAttemptedCompanyEnrichmentFetch(true);
setIsCompanyEnrichmentFetchInFlight(true);
+ setHasAttemptedCompanyEnrichmentFetch(true);
const fetchCompanyEnrichment = async () => {
try {
const { data } = await enrichWorkspaceCompany();
const result = data?.enrichWorkspaceCompany;
- if (result?.outcome !== WorkspaceCompanyEnrichmentOutcome.matched) {
+ if (!isDefined(result)) {
return;
}
+ // A response that lands after the settlement timeout must not reopen a
+ // step the user already moved past, so the server is told to drop it.
+ const hasAdvancedPastBookCallStep = getHasAdvancedPastBookCallStep(
+ store.get(currentUserState.atom)?.onboardingStatus,
+ );
+
+ const dropBookCallStep = async () => {
+ try {
+ await completeBookCallOnboardingStep();
+
+ return true;
+ } catch {
+ return false;
+ }
+ };
+
+ // The local flag mirrors what the server ended up with: a failed drop
+ // leaves the step pending on both sides instead of silently diverging.
+ const hasDroppedBookCallStep =
+ result.isBookCallOnboardingStepPending && hasAdvancedPastBookCallStep
+ ? await dropBookCallStep()
+ : false;
+
+ setCurrentUser((current) =>
+ setIsBookCallOnboardingStepPending(
+ current,
+ result.isBookCallOnboardingStepPending && !hasDroppedBookCallStep,
+ ),
+ );
+
const enrichment: WorkspaceCompanyEnrichment | null =
- result.enrichment ?? null;
+ result.outcome === WorkspaceCompanyEnrichmentOutcome.matched
+ ? (result.enrichment ?? null)
+ : null;
- if (!isDefined(enrichment)) {
- return;
+ if (isDefined(enrichment)) {
+ setCompanyEnrichment(enrichment);
}
-
- setCompanyEnrichment(enrichment);
} catch {
return;
} finally {
@@ -81,11 +121,14 @@ export const CompanyEnrichmentOnboardingEffect = () => {
hasAttemptedCompanyEnrichmentFetch,
companyEnrichment,
isOnboardingInProgress,
- isOnboardingAiChatEnabled,
+ isCompanyEnrichmentEnabled,
setHasAttemptedCompanyEnrichmentFetch,
setIsCompanyEnrichmentFetchInFlight,
setCompanyEnrichment,
+ setCurrentUser,
+ store,
enrichWorkspaceCompany,
+ completeBookCallOnboardingStep,
]);
return null;
diff --git a/packages/twenty-front/src/modules/onboarding/effect-components/PrefetchBookCallStepEffect.tsx b/packages/twenty-front/src/modules/onboarding/effect-components/PrefetchBookCallStepEffect.tsx
new file mode 100644
index 0000000000..5bdad344b2
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/effect-components/PrefetchBookCallStepEffect.tsx
@@ -0,0 +1,7 @@
+import { usePreloadCalForBookCallStep } from '@/onboarding/hooks/usePreloadCalForBookCallStep';
+
+export const PrefetchBookCallStepEffect = () => {
+ usePreloadCalForBookCallStep();
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/BookCallBookingSuccessEffect.test.tsx b/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/BookCallBookingSuccessEffect.test.tsx
new file mode 100644
index 0000000000..9dd0dfdf16
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/BookCallBookingSuccessEffect.test.tsx
@@ -0,0 +1,68 @@
+import { act, render } from '@testing-library/react';
+
+import { BookCallBookingSuccessEffect } from '@/onboarding/effect-components/BookCallBookingSuccessEffect';
+
+const mockCalApi = jest.fn();
+
+jest.mock('@calcom/embed-react', () => ({
+ getCalApi: () => Promise.resolve(mockCalApi),
+}));
+
+const renderEffect = async (onBookingSuccessful: () => void) => {
+ const view = render(
+ ,
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const subscription = mockCalApi.mock.calls.find(
+ ([action]) => action === 'on',
+ );
+
+ return {
+ view,
+ emitBookingSuccessful: subscription?.[1].callback as () => void,
+ };
+};
+
+describe('BookCallBookingSuccessEffect', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should subscribe to the booking success event', async () => {
+ const onBookingSuccessful = jest.fn();
+
+ const { emitBookingSuccessful } = await renderEffect(onBookingSuccessful);
+
+ expect(emitBookingSuccessful).toBeDefined();
+ });
+
+ it('should notify once even when the embed emits repeatedly', async () => {
+ const onBookingSuccessful = jest.fn();
+
+ const { emitBookingSuccessful } = await renderEffect(onBookingSuccessful);
+
+ act(() => {
+ emitBookingSuccessful();
+ emitBookingSuccessful();
+ });
+
+ expect(onBookingSuccessful).toHaveBeenCalledTimes(1);
+ });
+
+ it('should unsubscribe on unmount so listeners cannot stack up', async () => {
+ const onBookingSuccessful = jest.fn();
+
+ const { view } = await renderEffect(onBookingSuccessful);
+
+ view.unmount();
+
+ expect(mockCalApi).toHaveBeenCalledWith(
+ 'off',
+ expect.objectContaining({ action: 'bookingSuccessfulV2' }),
+ );
+ });
+});
diff --git a/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/CompanyEnrichmentOnboardingEffect.test.tsx b/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/CompanyEnrichmentOnboardingEffect.test.tsx
index cfc5fac0db..e626acd4c3 100644
--- a/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/CompanyEnrichmentOnboardingEffect.test.tsx
+++ b/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/CompanyEnrichmentOnboardingEffect.test.tsx
@@ -4,15 +4,19 @@ import { GraphQLError } from 'graphql';
import { Provider as JotaiProvider } from 'jotai';
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
-import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
+import { currentUserState } from '@/auth/states/currentUserState';
+import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
import { CompanyEnrichmentOnboardingEffect } from '@/onboarding/effect-components/CompanyEnrichmentOnboardingEffect';
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
+import { getIsBookCallOnboardingStepPending } from '@/onboarding/utils/getIsBookCallOnboardingStepPending';
+import { waitForCompanyEnrichmentSettlement } from '@/onboarding/utils/waitForCompanyEnrichmentSettlement';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
import {
+ CompleteBookCallOnboardingStepDocument,
EnrichWorkspaceCompanyDocument,
OnboardingStatus,
} from '~/generated-metadata/graphql';
@@ -44,10 +48,12 @@ const buildEnrichMock = ({
outcome,
enrichmentPayload,
countCall,
+ isBookCallOnboardingStepPending = false,
}: {
outcome: string;
enrichmentPayload: WorkspaceCompanyEnrichment | null;
countCall: () => void;
+ isBookCallOnboardingStepPending?: boolean;
}) => ({
request: { query: EnrichWorkspaceCompanyDocument },
result: () => {
@@ -59,6 +65,7 @@ const buildEnrichMock = ({
__typename: 'WorkspaceCompanyEnrichmentResult',
outcome,
enrichment: enrichmentPayload,
+ isBookCallOnboardingStepPending,
},
},
};
@@ -84,8 +91,32 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
beforeEach(() => {
resetJotaiStore();
localStorage.clear();
+ sessionStorage.clear();
mockOnboardingStatus.mockReturnValue(OnboardingStatus.PROFILE_CREATION);
- jotaiStore.set(isOnboardingAiChatEnabledState.atom, true);
+ jotaiStore.set(isCompanyEnrichmentEnabledState.atom, true);
+ });
+
+ it('does not fetch when enrichment has no consumer or api key', async () => {
+ jotaiStore.set(isCompanyEnrichmentEnabledState.atom, false);
+
+ let callCount = 0;
+ renderEffect([
+ buildEnrichMock({
+ outcome: 'matched',
+ enrichmentPayload: enrichment,
+ countCall: () => {
+ callCount += 1;
+ },
+ }),
+ ]);
+
+ await flushMutation();
+
+ expect(callCount).toBe(0);
+ expect(jotaiStore.get(companyEnrichmentState.atom)).toBeNull();
+ expect(jotaiStore.get(hasAttemptedCompanyEnrichmentFetchState.atom)).toBe(
+ false,
+ );
});
afterEach(() => {
@@ -128,6 +159,38 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
);
});
+ it('stays unsettled for a waiter while the fetch is in flight', async () => {
+ let hasSettled = false;
+
+ void waitForCompanyEnrichmentSettlement({
+ store: jotaiStore,
+ timeoutMs: 10_000,
+ }).then(() => {
+ hasSettled = true;
+ });
+
+ renderEffect([
+ buildEnrichMock({
+ outcome: 'matched',
+ enrichmentPayload: enrichment,
+ countCall: () => {},
+ }),
+ ]);
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(jotaiStore.get(hasAttemptedCompanyEnrichmentFetchState.atom)).toBe(
+ true,
+ );
+ expect(hasSettled).toBe(false);
+
+ await flushMutation();
+
+ expect(hasSettled).toBe(true);
+ });
+
it.each([OnboardingStatus.COMPLETED, OnboardingStatus.WORKSPACE_ACTIVATION])(
'does not fetch when the onboarding status is %s',
async (status) => {
@@ -151,26 +214,6 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
},
);
- it('does not fetch when onboarding AI chat is disabled', async () => {
- jotaiStore.set(isOnboardingAiChatEnabledState.atom, false);
-
- let callCount = 0;
- renderEffect([
- buildEnrichMock({
- outcome: 'matched',
- enrichmentPayload: enrichment,
- countCall: () => {
- callCount += 1;
- },
- }),
- ]);
-
- await flushMutation();
-
- expect(callCount).toBe(0);
- expect(jotaiStore.get(companyEnrichmentState.atom)).toBeNull();
- });
-
it('does not fetch when an enrichment is already stored', async () => {
jotaiStore.set(companyEnrichmentState.atom, enrichment);
@@ -214,6 +257,118 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
},
);
+ it('records the pending book-call step reported by the server', async () => {
+ jotaiStore.set(currentUserState.atom, { id: 'user-id' } as never);
+
+ renderEffect([
+ buildEnrichMock({
+ outcome: 'matched',
+ enrichmentPayload: enrichment,
+ countCall: () => {},
+ isBookCallOnboardingStepPending: true,
+ }),
+ ]);
+
+ await flushMutation();
+
+ expect(
+ getIsBookCallOnboardingStepPending(jotaiStore.get(currentUserState.atom)),
+ ).toBe(true);
+ });
+
+ it('records the pending book-call step even when the enrichment did not match', async () => {
+ jotaiStore.set(currentUserState.atom, { id: 'user-id' } as never);
+
+ renderEffect([
+ buildEnrichMock({
+ outcome: 'transientError',
+ enrichmentPayload: null,
+ countCall: () => {},
+ isBookCallOnboardingStepPending: true,
+ }),
+ ]);
+
+ await flushMutation();
+
+ expect(
+ getIsBookCallOnboardingStepPending(jotaiStore.get(currentUserState.atom)),
+ ).toBe(true);
+ });
+
+ it('drops a pending book-call step that lands after the user advanced past it', async () => {
+ mockOnboardingStatus.mockReturnValue(OnboardingStatus.PROFILE_CREATION);
+ jotaiStore.set(currentUserState.atom, {
+ id: 'user-id',
+ onboardingStatus: OnboardingStatus.PLAN_REQUIRED,
+ } as never);
+
+ let hasClearedBookCallStep = false;
+
+ renderEffect([
+ buildEnrichMock({
+ outcome: 'matched',
+ enrichmentPayload: enrichment,
+ countCall: () => {},
+ isBookCallOnboardingStepPending: true,
+ }),
+ {
+ request: { query: CompleteBookCallOnboardingStepDocument },
+ result: () => {
+ hasClearedBookCallStep = true;
+
+ return {
+ data: {
+ completeBookCallOnboardingStep: {
+ __typename: 'OnboardingStepSuccess',
+ success: true,
+ },
+ },
+ };
+ },
+ },
+ ]);
+
+ await flushMutation();
+
+ expect(
+ getIsBookCallOnboardingStepPending(jotaiStore.get(currentUserState.atom)),
+ ).toBe(false);
+ expect(hasClearedBookCallStep).toBe(true);
+ expect(jotaiStore.get(companyEnrichmentState.atom)).toMatchObject({
+ domain: 'acme.com',
+ });
+ });
+
+ it('keeps the step pending locally when clearing it on the server fails', async () => {
+ jotaiStore.set(currentUserState.atom, {
+ id: 'user-id',
+ onboardingStatus: OnboardingStatus.PLAN_REQUIRED,
+ } as never);
+
+ renderEffect([
+ buildEnrichMock({
+ outcome: 'matched',
+ enrichmentPayload: enrichment,
+ countCall: () => {},
+ isBookCallOnboardingStepPending: true,
+ }),
+ {
+ request: { query: CompleteBookCallOnboardingStepDocument },
+ result: { errors: [new GraphQLError('Internal server error')] },
+ },
+ ]);
+
+ await flushMutation();
+
+ // The server still has the offer, so local state must not claim otherwise.
+ expect(
+ getIsBookCallOnboardingStepPending(jotaiStore.get(currentUserState.atom)),
+ ).toBe(true);
+ expect(jotaiStore.get(companyEnrichmentState.atom)).toMatchObject({
+ domain: 'acme.com',
+ });
+ });
+
it('stores nothing when the mutation fails', async () => {
renderEffect([
{
diff --git a/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/PrefetchBookCallStepEffect.test.tsx b/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/PrefetchBookCallStepEffect.test.tsx
new file mode 100644
index 0000000000..b3583a70e6
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/effect-components/__tests__/PrefetchBookCallStepEffect.test.tsx
@@ -0,0 +1,103 @@
+import { act, render } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+
+import { currentUserState } from '@/auth/states/currentUserState';
+import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
+import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
+import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
+import { PrefetchBookCallStepEffect } from '@/onboarding/effect-components/PrefetchBookCallStepEffect';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
+
+const mockCalApi = jest.fn();
+
+jest.mock('@calcom/embed-react', () => ({
+ getCalApi: () => Promise.resolve(mockCalApi),
+}));
+
+const renderEffect = async ({
+ isBookCallOnboardingStepEnabled,
+ isBookCallOnboardingStepPending,
+ calendarBookingPageId,
+}: {
+ isBookCallOnboardingStepEnabled: boolean;
+ isBookCallOnboardingStepPending: boolean;
+ calendarBookingPageId: string | null;
+}) => {
+ jotaiStore.set(
+ isBookCallOnboardingStepEnabledState.atom,
+ isBookCallOnboardingStepEnabled,
+ );
+ jotaiStore.set(calendarBookingPageIdState.atom, calendarBookingPageId);
+ jotaiStore.set(currentUserState.atom, {
+ id: 'user-id',
+ userVars: {
+ [ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]:
+ isBookCallOnboardingStepPending,
+ },
+ } as never);
+
+ render(
+
+
+ ,
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+};
+
+const getPreloadCalls = () =>
+ mockCalApi.mock.calls.filter(([action]) => action === 'preload');
+
+describe('PrefetchBookCallStepEffect', () => {
+ beforeEach(() => {
+ resetJotaiStore();
+ jest.clearAllMocks();
+ });
+
+ it('should warm the booking page when the step is pending and configured', async () => {
+ await renderEffect({
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: true,
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ });
+
+ expect(getPreloadCalls()).toEqual([
+ ['preload', { calLink: 'team/twenty/talk-to-us' }],
+ ]);
+ });
+
+ it('should not warm anything when the user is not pending the step', async () => {
+ await renderEffect({
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: false,
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ });
+
+ expect(getPreloadCalls()).toHaveLength(0);
+ });
+
+ it('should not warm anything when the step is disabled', async () => {
+ await renderEffect({
+ isBookCallOnboardingStepEnabled: false,
+ isBookCallOnboardingStepPending: true,
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ });
+
+ expect(getPreloadCalls()).toHaveLength(0);
+ });
+
+ it('should not warm anything without a booking page', async () => {
+ await renderEffect({
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: true,
+ calendarBookingPageId: null,
+ });
+
+ expect(getPreloadCalls()).toHaveLength(0);
+ });
+});
diff --git a/packages/twenty-front/src/modules/onboarding/graphql/mutations/completeBookCallOnboardingStep.ts b/packages/twenty-front/src/modules/onboarding/graphql/mutations/completeBookCallOnboardingStep.ts
new file mode 100644
index 0000000000..2cca15c9fc
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/graphql/mutations/completeBookCallOnboardingStep.ts
@@ -0,0 +1,9 @@
+import { gql } from '@apollo/client';
+
+export const COMPLETE_BOOK_CALL_ONBOARDING_STEP = gql`
+ mutation CompleteBookCallOnboardingStep {
+ completeBookCallOnboardingStep {
+ success
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/onboarding/graphql/mutations/enrichWorkspaceCompany.ts b/packages/twenty-front/src/modules/onboarding/graphql/mutations/enrichWorkspaceCompany.ts
index 3e4ee0a609..994f38a7de 100644
--- a/packages/twenty-front/src/modules/onboarding/graphql/mutations/enrichWorkspaceCompany.ts
+++ b/packages/twenty-front/src/modules/onboarding/graphql/mutations/enrichWorkspaceCompany.ts
@@ -5,6 +5,7 @@ export const ENRICH_WORKSPACE_COMPANY = gql`
enrichWorkspaceCompany {
outcome
enrichment
+ isBookCallOnboardingStepPending
}
}
`;
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useCompleteBookCallOnboardingStep.test.tsx b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useCompleteBookCallOnboardingStep.test.tsx
new file mode 100644
index 0000000000..9af804e4c2
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useCompleteBookCallOnboardingStep.test.tsx
@@ -0,0 +1,135 @@
+import { MockedProvider } from '@apollo/client/testing/react';
+import { act, renderHook } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+import { createElement, type ReactNode } from 'react';
+import { AppPath } from 'twenty-shared/types';
+
+import { currentUserState } from '@/auth/states/currentUserState';
+import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
+import { billingState } from '@/client-config/states/billingState';
+import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
+import { useCompleteBookCallOnboardingStep } from '@/onboarding/hooks/useCompleteBookCallOnboardingStep';
+import { getIsBookCallOnboardingStepPending } from '@/onboarding/utils/getIsBookCallOnboardingStepPending';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
+import { CompleteBookCallOnboardingStepDocument } from '~/generated-metadata/graphql';
+import { mockCurrentWorkspace } from '~/testing/mock-data/users';
+
+const mockNavigate = jest.fn();
+
+jest.mock('react-router-dom', () => ({
+ useNavigate: () => mockNavigate,
+}));
+
+const mockSetNextOnboardingStatus = jest.fn();
+
+jest.mock('@/onboarding/hooks/useSetNextOnboardingStatus', () => ({
+ useSetNextOnboardingStatus: () => mockSetNextOnboardingStatus,
+}));
+
+const mutationMock = {
+ request: { query: CompleteBookCallOnboardingStepDocument },
+ result: {
+ data: {
+ completeBookCallOnboardingStep: {
+ __typename: 'OnboardingStepSuccess',
+ success: true,
+ },
+ },
+ },
+};
+
+const renderCompleteHook = ({
+ isBillingEnabled,
+ withSubscription,
+}: {
+ isBillingEnabled: boolean;
+ withSubscription: boolean;
+}) => {
+ jotaiStore.set(currentWorkspaceState.atom, {
+ ...mockCurrentWorkspace,
+ billingSubscriptions: withSubscription
+ ? mockCurrentWorkspace.billingSubscriptions
+ : [],
+ });
+ jotaiStore.set(billingState.atom, {
+ __typename: 'Billing',
+ isBillingEnabled,
+ trialPeriods: [],
+ } as never);
+
+ const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(
+ MockedProvider,
+ { mocks: [mutationMock] as never },
+ createElement(JotaiProvider, { store: jotaiStore }, children),
+ );
+
+ return renderHook(() => useCompleteBookCallOnboardingStep(), { wrapper });
+};
+
+describe('useCompleteBookCallOnboardingStep', () => {
+ beforeEach(() => {
+ resetJotaiStore();
+ jest.clearAllMocks();
+ });
+
+ it('should navigate to the plan step when a plan is still required', async () => {
+ const { result } = renderCompleteHook({
+ isBillingEnabled: true,
+ withSubscription: false,
+ });
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(AppPath.PlanRequired);
+ });
+
+ it.each([
+ { isBillingEnabled: false, withSubscription: false },
+ { isBillingEnabled: true, withSubscription: true },
+ ])(
+ 'should leave routing to the redirect engine when no plan is required (%o)',
+ async (options) => {
+ const { result } = renderCompleteHook(options);
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ },
+ );
+
+ it('should clear the pending flag before advancing so the step cannot reopen', async () => {
+ jotaiStore.set(currentUserState.atom, {
+ id: 'user-id',
+ userVars: { [ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]: true },
+ } as never);
+
+ const { result } = renderCompleteHook({
+ isBillingEnabled: true,
+ withSubscription: false,
+ });
+
+ mockSetNextOnboardingStatus.mockImplementation(() => {
+ expect(
+ getIsBookCallOnboardingStepPending(
+ jotaiStore.get(currentUserState.atom),
+ ),
+ ).toBe(false);
+ });
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
+ });
+});
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useInviteTeam.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useInviteTeam.test.ts
new file mode 100644
index 0000000000..0736910f47
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useInviteTeam.test.ts
@@ -0,0 +1,196 @@
+import { i18n } from '@lingui/core';
+import { I18nProvider } from '@lingui/react';
+import { act, renderHook } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+import { SOURCE_LOCALE } from 'twenty-shared/translations';
+import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
+
+import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
+import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
+import { useInviteTeam } from '@/onboarding/hooks/useInviteTeam';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
+
+const mockSendInvitation = jest.fn();
+const mockSetNextOnboardingStatus = jest.fn();
+const mockWaitForCompanyEnrichmentSettlement = jest.fn();
+
+jest.mock('@/workspace-invitation/hooks/useCreateWorkspaceInvitation', () => ({
+ useCreateWorkspaceInvitation: () => ({
+ sendInvitation: mockSendInvitation,
+ }),
+}));
+
+jest.mock('@/onboarding/hooks/useSetNextOnboardingStatus', () => ({
+ useSetNextOnboardingStatus: () => mockSetNextOnboardingStatus,
+}));
+
+jest.mock('@/onboarding/utils/waitForCompanyEnrichmentSettlement', () => ({
+ waitForCompanyEnrichmentSettlement: (...args: unknown[]) =>
+ mockWaitForCompanyEnrichmentSettlement(...args),
+}));
+
+jest.mock('@apollo/client/react', () => ({
+ useQuery: () => ({ data: undefined, loading: false }),
+}));
+
+jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
+ useSnackBar: () => ({ enqueueSuccessSnackBar: jest.fn() }),
+}));
+
+jest.mock('@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement', () => ({
+ useHotkeysOnFocusedElement: jest.fn(),
+}));
+
+dynamicActivate(SOURCE_LOCALE);
+
+const renderInviteTeam = () =>
+ renderHook(() => useInviteTeam(), {
+ wrapper: ({ children }) =>
+ JotaiProvider({
+ store: jotaiStore,
+ children: I18nProvider({ i18n, children }),
+ }),
+ });
+
+describe('useInviteTeam', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ resetJotaiStore();
+ jest.clearAllMocks();
+ mockSendInvitation.mockResolvedValue({});
+ mockWaitForCompanyEnrichmentSettlement.mockResolvedValue(undefined);
+ jotaiStore.set(isBookCallOnboardingStepEnabledState.atom, true);
+ jotaiStore.set(isCompanyEnrichmentEnabledState.atom, true);
+ });
+
+ it('should not wait for an enrichment that will never run', async () => {
+ jotaiStore.set(isCompanyEnrichmentEnabledState.atom, false);
+
+ const { result } = renderInviteTeam();
+
+ await act(async () => {
+ await result.current.handleSkip();
+ });
+
+ expect(mockWaitForCompanyEnrichmentSettlement).not.toHaveBeenCalled();
+ expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
+ });
+
+ it('should not wait for the enrichment when the book-call step is disabled', async () => {
+ jotaiStore.set(isBookCallOnboardingStepEnabledState.atom, false);
+
+ const { result } = renderInviteTeam();
+
+ await act(async () => {
+ await result.current.handleSkip();
+ });
+
+ expect(mockWaitForCompanyEnrichmentSettlement).not.toHaveBeenCalled();
+ expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
+ });
+
+ it('should wait for the enrichment answer before advancing', async () => {
+ let resolveCompanyEnrichmentSettlement: () => void = () => {};
+
+ mockWaitForCompanyEnrichmentSettlement.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCompanyEnrichmentSettlement = resolve;
+ }),
+ );
+
+ const { result } = renderInviteTeam();
+
+ let hasSkipResolved = false;
+
+ await act(async () => {
+ void result.current.handleSkip().then(() => {
+ hasSkipResolved = true;
+ });
+ });
+
+ expect(mockWaitForCompanyEnrichmentSettlement).toHaveBeenCalled();
+ expect(hasSkipResolved).toBe(false);
+ expect(mockSetNextOnboardingStatus).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolveCompanyEnrichmentSettlement();
+ });
+
+ expect(hasSkipResolved).toBe(true);
+ expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
+ });
+
+ it('should start waiting for the enrichment before the invitation resolves', async () => {
+ let resolveInvitation: (value: unknown) => void = () => {};
+
+ mockSendInvitation.mockReturnValue(
+ new Promise((resolve) => {
+ resolveInvitation = resolve;
+ }),
+ );
+
+ const { result } = renderInviteTeam();
+
+ act(() => {
+ void result.current.handleSkip();
+ });
+
+ expect(mockWaitForCompanyEnrichmentSettlement).toHaveBeenCalled();
+ expect(mockSetNextOnboardingStatus).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolveInvitation({});
+ });
+ });
+
+ it('should disable the form while the submission is still in flight', async () => {
+ let resolveInvitation: (value: unknown) => void = () => {};
+
+ mockSendInvitation.mockReturnValue(
+ new Promise((resolve) => {
+ resolveInvitation = resolve;
+ }),
+ );
+
+ const { result } = renderInviteTeam();
+
+ act(() => {
+ void result.current.handleSkip();
+ });
+
+ expect(result.current.isNavigating).toBe(true);
+
+ await act(async () => {
+ resolveInvitation({});
+ });
+ });
+
+ it('should stay disabled after advancing', async () => {
+ const { result } = renderInviteTeam();
+
+ await act(async () => {
+ await result.current.handleSkip();
+ });
+
+ expect(result.current.isNavigating).toBe(true);
+ });
+
+ it('should re-enable submission when sending the invitations fails', async () => {
+ mockSendInvitation.mockResolvedValue({ error: new Error('network error') });
+
+ const { result } = renderInviteTeam();
+
+ await act(async () => {
+ await expect(result.current.handleSkip()).rejects.toThrow(
+ 'network error',
+ );
+ });
+
+ expect(result.current.isNavigating).toBe(false);
+ expect(mockSetNextOnboardingStatus).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts
index 276419e71b..c6f7bdb91b 100644
--- a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts
+++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts
@@ -6,7 +6,9 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
+import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
+import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
@@ -32,6 +34,8 @@ type RenderHooksOptions = {
isBillingEnabled?: boolean;
withOneWorkspaceMember?: boolean;
isOnboardingAiChatEnabled?: boolean;
+ isBookCallOnboardingStepEnabled?: boolean;
+ isBookCallOnboardingStepPending?: boolean;
};
const renderHooks = (
@@ -41,6 +45,8 @@ const renderHooks = (
isBillingEnabled = false,
withOneWorkspaceMember = true,
isOnboardingAiChatEnabled = false,
+ isBookCallOnboardingStepEnabled = false,
+ isBookCallOnboardingStepPending = false,
}: RenderHooksOptions = {},
) => {
jotaiStore.set(
@@ -56,6 +62,9 @@ const renderHooks = (
);
const setCurrentWorkspace = useSetAtomState(currentWorkspaceState);
const setBilling = useSetAtomState(billingState);
+ const setIsBookCallOnboardingStepEnabled = useSetAtomState(
+ isBookCallOnboardingStepEnabledState,
+ );
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const isWelcomeAnimationVisible = useAtomStateValue(
isWelcomeAnimationVisibleState,
@@ -69,6 +78,7 @@ const renderHooks = (
setCurrentWorkspace,
setCurrentUserWorkspace,
setBilling,
+ setIsBookCallOnboardingStepEnabled,
setNextOnboardingStatus,
isWelcomeAnimationVisible,
shouldOpenAiChatAfterOnboarding,
@@ -79,7 +89,15 @@ const renderHooks = (
},
);
act(() => {
- result.current.setCurrentUser({ ...mockedUserData, onboardingStatus });
+ result.current.setCurrentUser({
+ ...mockedUserData,
+ onboardingStatus,
+ userVars: {
+ ...mockedUserData.userVars,
+ [ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]:
+ isBookCallOnboardingStepPending,
+ },
+ });
result.current.setCurrentUserWorkspace(mockedUserData.currentUserWorkspace);
result.current.setCurrentWorkspace({
...mockCurrentWorkspace,
@@ -93,6 +111,9 @@ const renderHooks = (
isBillingEnabled,
trialPeriods: [],
});
+ result.current.setIsBookCallOnboardingStepEnabled(
+ isBookCallOnboardingStepEnabled,
+ );
});
act(() => {
result.current.setNextOnboardingStatus();
@@ -108,6 +129,7 @@ const renderHooks = (
describe('useSetNextOnboardingStatus', () => {
beforeEach(() => {
sessionStorage.clear();
+ localStorage.clear();
resetJotaiStore();
});
@@ -235,6 +257,93 @@ describe('useSetNextOnboardingStatus', () => {
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
});
+ it('should book a call after inviting the team when the server flagged the step', () => {
+ const {
+ nextOnboardingStatus,
+ isWelcomeAnimationVisible,
+ shouldOpenAiChatAfterOnboarding,
+ } = renderHooks(OnboardingStatus.INVITE_TEAM, {
+ isBillingEnabled: true,
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: true,
+ });
+ expect(nextOnboardingStatus).toEqual(OnboardingStatus.BOOK_CALL);
+ expect(isWelcomeAnimationVisible).toBe(false);
+ expect(shouldOpenAiChatAfterOnboarding).toBe(false);
+ });
+
+ it('should book a call after profile creation when more than 1 workspaceMember exist', () => {
+ const { nextOnboardingStatus } = renderHooks(
+ OnboardingStatus.PROFILE_CREATION,
+ {
+ withOneWorkspaceMember: false,
+ isBillingEnabled: true,
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: true,
+ },
+ );
+ expect(nextOnboardingStatus).toEqual(OnboardingStatus.BOOK_CALL);
+ });
+
+ it('should skip the book-call step once the workspace has a subscription, matching the server', () => {
+ const { nextOnboardingStatus } = renderHooks(OnboardingStatus.INVITE_TEAM, {
+ isBillingEnabled: true,
+ withSubscription: true,
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: true,
+ });
+ expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
+ });
+
+ it.each([
+ {
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: false,
+ },
+ {
+ isBookCallOnboardingStepEnabled: false,
+ isBookCallOnboardingStepPending: true,
+ },
+ {
+ isBookCallOnboardingStepEnabled: false,
+ isBookCallOnboardingStepPending: false,
+ },
+ ])(
+ 'should skip the book-call step when enabled is $isBookCallOnboardingStepEnabled and pending is $isBookCallOnboardingStepPending',
+ (options) => {
+ const { nextOnboardingStatus } = renderHooks(
+ OnboardingStatus.INVITE_TEAM,
+ { isBillingEnabled: true, ...options },
+ );
+ expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
+ },
+ );
+
+ it('should require a plan after booking a call when billing is enabled and the workspace has no subscription', () => {
+ const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
+ OnboardingStatus.BOOK_CALL,
+ {
+ isBillingEnabled: true,
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: true,
+ },
+ );
+ expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
+ expect(isWelcomeAnimationVisible).toBe(false);
+ });
+
+ it('should complete after booking a call when billing is disabled', () => {
+ const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
+ OnboardingStatus.BOOK_CALL,
+ {
+ isBookCallOnboardingStepEnabled: true,
+ isBookCallOnboardingStepPending: true,
+ },
+ );
+ expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
+ expect(isWelcomeAnimationVisible).toBe(true);
+ });
+
it('should not show the welcome animation when the onboarding was already completed', () => {
const {
nextOnboardingStatus,
@@ -274,4 +383,89 @@ describe('useSetNextOnboardingStatus', () => {
expect(isWelcomeAnimationVisible).toBe(true);
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
});
+
+ it('should stay on the plan step when advancing from it without a subscription', () => {
+ const { nextOnboardingStatus } = renderHooks(
+ OnboardingStatus.PLAN_REQUIRED,
+ { isBillingEnabled: true, withSubscription: false },
+ );
+ expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
+ });
+
+ it('should complete when advancing from the plan step with a subscription', () => {
+ const { nextOnboardingStatus } = renderHooks(
+ OnboardingStatus.PLAN_REQUIRED,
+ { isBillingEnabled: true, withSubscription: true },
+ );
+ expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
+ });
+
+ it('should honour a pending book-call flag that arrives after the callback was captured', () => {
+ jotaiStore.set(currentUserState.atom, {
+ ...mockedUserData,
+ onboardingStatus: OnboardingStatus.INVITE_TEAM,
+ });
+ jotaiStore.set(currentWorkspaceState.atom, {
+ ...mockCurrentWorkspace,
+ billingSubscriptions: [],
+ workspaceMembersCount: 1,
+ });
+ jotaiStore.set(billingState.atom, {
+ __typename: 'Billing',
+ isBillingEnabled: true,
+ trialPeriods: [],
+ } as never);
+ jotaiStore.set(isBookCallOnboardingStepEnabledState.atom, true);
+
+ const { result } = renderHook(() => useSetNextOnboardingStatus(), {
+ wrapper: Wrapper,
+ });
+
+ const advanceCapturedBeforeEnrichment = result.current;
+
+ act(() => {
+ jotaiStore.set(currentUserState.atom, (current) => ({
+ ...current!,
+ userVars: {
+ ...current?.userVars,
+ [ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]: true,
+ },
+ }));
+ advanceCapturedBeforeEnrichment();
+ });
+
+ expect(jotaiStore.get(currentUserState.atom)?.onboardingStatus).toEqual(
+ OnboardingStatus.BOOK_CALL,
+ );
+ });
+
+ it('should still sync emails when the server status landed before advancing', () => {
+ jotaiStore.set(currentUserState.atom, {
+ ...mockedUserData,
+ onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION,
+ });
+ jotaiStore.set(currentWorkspaceState.atom, {
+ ...mockCurrentWorkspace,
+ billingSubscriptions: [],
+ workspaceMembersCount: 1,
+ });
+
+ const { result } = renderHook(() => useSetNextOnboardingStatus(), {
+ wrapper: Wrapper,
+ });
+
+ const advanceCapturedBeforeActivation = result.current;
+
+ act(() => {
+ jotaiStore.set(currentUserState.atom, {
+ ...mockedUserData,
+ onboardingStatus: OnboardingStatus.SYNC_EMAIL,
+ });
+ advanceCapturedBeforeActivation();
+ });
+
+ expect(jotaiStore.get(currentUserState.atom)?.onboardingStatus).toEqual(
+ OnboardingStatus.SYNC_EMAIL,
+ );
+ });
});
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useCompleteBookCallOnboardingStep.ts b/packages/twenty-front/src/modules/onboarding/hooks/useCompleteBookCallOnboardingStep.ts
new file mode 100644
index 0000000000..2ba7154652
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/hooks/useCompleteBookCallOnboardingStep.ts
@@ -0,0 +1,39 @@
+import { currentUserState } from '@/auth/states/currentUserState';
+import { useIsPlanRequired } from '@/onboarding/hooks/useIsPlanRequired';
+import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
+import { setIsBookCallOnboardingStepPending } from '@/onboarding/utils/setIsBookCallOnboardingStepPending';
+import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
+import { useMutation } from '@apollo/client/react';
+import { useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { AppPath } from 'twenty-shared/types';
+import { CompleteBookCallOnboardingStepDocument } from '~/generated-metadata/graphql';
+
+export const useCompleteBookCallOnboardingStep = () => {
+ const navigate = useNavigate();
+ const setNextOnboardingStatus = useSetNextOnboardingStatus();
+ const setCurrentUser = useSetAtomState(currentUserState);
+ const isPlanRequired = useIsPlanRequired();
+ const [completeBookCallOnboardingStepMutation] = useMutation(
+ CompleteBookCallOnboardingStepDocument,
+ );
+
+ return useCallback(async () => {
+ await completeBookCallOnboardingStepMutation();
+
+ setCurrentUser((current) =>
+ setIsBookCallOnboardingStepPending(current, false),
+ );
+ setNextOnboardingStatus();
+
+ if (isPlanRequired) {
+ navigate(AppPath.PlanRequired);
+ }
+ }, [
+ completeBookCallOnboardingStepMutation,
+ setCurrentUser,
+ setNextOnboardingStatus,
+ isPlanRequired,
+ navigate,
+ ]);
+};
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
index 8f7b571bc2..4b2c82b247 100644
--- a/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
+++ b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
@@ -1,6 +1,9 @@
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
+import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
+import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
+import { waitForCompanyEnrichmentSettlement } from '@/onboarding/utils/waitForCompanyEnrichmentSettlement';
import { PageFocusId } from '@/types/PageFocusId';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
@@ -10,6 +13,7 @@ import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCr
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client/react';
+import { useStore } from 'jotai';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
import { Key } from 'ts-key-enum';
@@ -30,6 +34,13 @@ export const useInviteTeam = () => {
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
const onboardingConfig = useAtomStateValue(onboardingConfigState);
+ const isBookCallOnboardingStepEnabled = useAtomStateValue(
+ isBookCallOnboardingStepEnabledState,
+ );
+ const isCompanyEnrichmentEnabled = useAtomStateValue(
+ isCompanyEnrichmentEnabledState,
+ );
+ const store = useStore();
const [isNavigating, setIsNavigating] = useState(false);
@@ -130,38 +141,57 @@ export const useInviteTeam = () => {
),
);
- const result = await sendInvitation({ emails });
-
- if (isDefined(result.error)) {
- throw result.error;
- }
-
- const creditsRewardPerUser =
- onboardingConfig?.inviteTeamCreditsRewardPerUser ?? 0;
-
- setOnboardingFreeCredits((current) => ({
- ...current,
- inviteTeam: emails.length * creditsRewardPerUser,
- }));
-
- if (emails.length > 0) {
- enqueueSuccessSnackBar({
- message: t`Invite link sent to email addresses`,
- options: {
- duration: 2000,
- },
- });
- }
-
- setNextOnboardingStatus();
setIsNavigating(true);
+
+ try {
+ // Only wait when enrichment is actually going to run, otherwise the
+ // settlement never resolves and every submit burns the full timeout.
+ const companyEnrichmentSettlement =
+ isBookCallOnboardingStepEnabled && isCompanyEnrichmentEnabled
+ ? waitForCompanyEnrichmentSettlement({ store })
+ : Promise.resolve();
+
+ const result = await sendInvitation({ emails });
+
+ if (isDefined(result.error)) {
+ throw result.error;
+ }
+
+ const creditsRewardPerUser =
+ onboardingConfig?.inviteTeamCreditsRewardPerUser ?? 0;
+
+ setOnboardingFreeCredits((current) => ({
+ ...current,
+ inviteTeam: emails.length * creditsRewardPerUser,
+ }));
+
+ if (emails.length > 0) {
+ enqueueSuccessSnackBar({
+ message: t`Invite link sent to email addresses`,
+ options: {
+ duration: 2000,
+ },
+ });
+ }
+
+ await companyEnrichmentSettlement;
+
+ setNextOnboardingStatus();
+ } catch (error) {
+ setIsNavigating(false);
+
+ throw error;
+ }
},
[
enqueueSuccessSnackBar,
+ isBookCallOnboardingStepEnabled,
+ isCompanyEnrichmentEnabled,
onboardingConfig?.inviteTeamCreditsRewardPerUser,
sendInvitation,
setNextOnboardingStatus,
setOnboardingFreeCredits,
+ store,
t,
],
);
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/usePreloadCalForBookCallStep.ts b/packages/twenty-front/src/modules/onboarding/hooks/usePreloadCalForBookCallStep.ts
new file mode 100644
index 0000000000..1e3a7ea412
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/hooks/usePreloadCalForBookCallStep.ts
@@ -0,0 +1,48 @@
+import { getCalApi } from '@calcom/embed-react';
+import { isNonEmptyString } from '@sniptt/guards';
+import { useEffect } from 'react';
+
+import { currentUserState } from '@/auth/states/currentUserState';
+import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
+import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
+import { getIsBookCallOnboardingStepPending } from '@/onboarding/utils/getIsBookCallOnboardingStepPending';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+
+export const usePreloadCalForBookCallStep = () => {
+ const isBookCallOnboardingStepEnabled = useAtomStateValue(
+ isBookCallOnboardingStepEnabledState,
+ );
+ const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
+ const currentUser = useAtomStateValue(currentUserState);
+
+ const isBookCallOnboardingStepPending =
+ getIsBookCallOnboardingStepPending(currentUser);
+
+ useEffect(() => {
+ if (
+ !isBookCallOnboardingStepEnabled ||
+ !isBookCallOnboardingStepPending ||
+ !isNonEmptyString(calendarBookingPageId)
+ ) {
+ return;
+ }
+
+ // Warms the Cal.com embed script and the booking page itself while the user
+ // is still on an earlier step, so the embed is not fetched from scratch.
+ const preloadBookingPage = async () => {
+ try {
+ const calApi = await getCalApi();
+
+ calApi('preload', { calLink: calendarBookingPageId });
+ } catch {
+ return;
+ }
+ };
+
+ void preloadBookingPage();
+ }, [
+ isBookCallOnboardingStepEnabled,
+ isBookCallOnboardingStepPending,
+ calendarBookingPageId,
+ ]);
+};
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts
index b1c4ffe298..86fe687283 100644
--- a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts
+++ b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts
@@ -9,33 +9,46 @@ import {
currentWorkspaceState,
} from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
+import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
import { getHasJustCompletedOnboarding } from '@/onboarding/utils/getHasJustCompletedOnboarding';
+import { getIsBookCallOnboardingStepPending } from '@/onboarding/utils/getIsBookCallOnboardingStepPending';
import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { useStore } from 'jotai';
import { useCallback } from 'react';
import { OnboardingStatus } from '~/generated-metadata/graphql';
-import { useStore } from 'jotai';
type GetNextOnboardingStatusArgs = {
currentUser: CurrentUser | null;
currentWorkspace: CurrentWorkspace | null;
isBillingEnabled: boolean;
+ isBookCallRequired: boolean;
};
const getNextOnboardingStatus = ({
currentUser,
currentWorkspace,
isBillingEnabled,
+ isBookCallRequired,
}: GetNextOnboardingStatusArgs) => {
const isPlanRequired = getIsPlanRequired({
isBillingEnabled,
currentWorkspace,
});
+ const statusAfterBookCall = isPlanRequired
+ ? OnboardingStatus.PLAN_REQUIRED
+ : OnboardingStatus.COMPLETED;
+
+ const statusAfterInviteTeam =
+ isBookCallRequired && isPlanRequired
+ ? OnboardingStatus.BOOK_CALL
+ : statusAfterBookCall;
+
if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) {
return OnboardingStatus.SYNC_EMAIL;
}
@@ -55,14 +68,16 @@ const getNextOnboardingStatus = ({
if (currentWorkspace?.workspaceMembersCount === 1) {
return OnboardingStatus.INVITE_TEAM;
}
- return isPlanRequired
- ? OnboardingStatus.PLAN_REQUIRED
- : OnboardingStatus.COMPLETED;
+ return statusAfterInviteTeam;
}
if (currentUser?.onboardingStatus === OnboardingStatus.INVITE_TEAM) {
- return isPlanRequired
- ? OnboardingStatus.PLAN_REQUIRED
- : OnboardingStatus.COMPLETED;
+ return statusAfterInviteTeam;
+ }
+ if (
+ currentUser?.onboardingStatus === OnboardingStatus.BOOK_CALL ||
+ currentUser?.onboardingStatus === OnboardingStatus.PLAN_REQUIRED
+ ) {
+ return statusAfterBookCall;
}
return OnboardingStatus.COMPLETED;
};
@@ -82,7 +97,11 @@ export const useSetNextOnboardingStatus = () => {
currentUser,
currentWorkspace,
isBillingEnabled,
+ isBookCallRequired:
+ store.get(isBookCallOnboardingStepEnabledState.atom) &&
+ getIsBookCallOnboardingStepPending(store.get(currentUserState.atom)),
});
+
store.set(currentUserState.atom, (current) => {
if (isDefined(current)) {
return {
diff --git a/packages/twenty-front/src/modules/onboarding/utils/__tests__/waitForCompanyEnrichmentSettlement.test.ts b/packages/twenty-front/src/modules/onboarding/utils/__tests__/waitForCompanyEnrichmentSettlement.test.ts
new file mode 100644
index 0000000000..fdcc2f083e
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/utils/__tests__/waitForCompanyEnrichmentSettlement.test.ts
@@ -0,0 +1,99 @@
+import { createStore } from 'jotai';
+
+import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
+import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
+import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
+import { waitForCompanyEnrichmentSettlement } from '@/onboarding/utils/waitForCompanyEnrichmentSettlement';
+
+const trackResolution = (settlement: Promise) => {
+ const resolution = { hasResolved: false };
+
+ void settlement.then(() => {
+ resolution.hasResolved = true;
+ });
+
+ return resolution;
+};
+
+describe('waitForCompanyEnrichmentSettlement', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+
+ it('should resolve immediately when the fetch already settled', async () => {
+ const store = createStore();
+
+ store.set(hasAttemptedCompanyEnrichmentFetchState.atom, true);
+
+ await expect(
+ waitForCompanyEnrichmentSettlement({ store, timeoutMs: 10_000 }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('should resolve immediately when the enrichment is already known', async () => {
+ const store = createStore();
+
+ store.set(companyEnrichmentState.atom, { domain: 'acme.com' } as never);
+
+ await expect(
+ waitForCompanyEnrichmentSettlement({ store, timeoutMs: 10_000 }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('should wait for a fetch that has not started yet', async () => {
+ const store = createStore();
+
+ const settlement = waitForCompanyEnrichmentSettlement({
+ store,
+ timeoutMs: 10_000,
+ });
+ const resolution = trackResolution(settlement);
+
+ await Promise.resolve();
+ expect(resolution.hasResolved).toBe(false);
+
+ store.set(isCompanyEnrichmentFetchInFlightState.atom, true);
+ store.set(hasAttemptedCompanyEnrichmentFetchState.atom, true);
+
+ await Promise.resolve();
+ expect(resolution.hasResolved).toBe(false);
+
+ store.set(isCompanyEnrichmentFetchInFlightState.atom, false);
+
+ await expect(settlement).resolves.toBeUndefined();
+ });
+
+ it('should resolve as soon as the fetch settles', async () => {
+ const store = createStore();
+
+ store.set(hasAttemptedCompanyEnrichmentFetchState.atom, true);
+ store.set(isCompanyEnrichmentFetchInFlightState.atom, true);
+
+ const settlement = waitForCompanyEnrichmentSettlement({
+ store,
+ timeoutMs: 10_000,
+ });
+ const resolution = trackResolution(settlement);
+
+ await Promise.resolve();
+ expect(resolution.hasResolved).toBe(false);
+
+ store.set(isCompanyEnrichmentFetchInFlightState.atom, false);
+
+ await expect(settlement).resolves.toBeUndefined();
+ });
+
+ it('should resolve on timeout so a slow enrichment cannot block onboarding', async () => {
+ const store = createStore();
+
+ store.set(hasAttemptedCompanyEnrichmentFetchState.atom, true);
+ store.set(isCompanyEnrichmentFetchInFlightState.atom, true);
+
+ await expect(
+ waitForCompanyEnrichmentSettlement({ store, timeoutMs: 1 }),
+ ).resolves.toBeUndefined();
+
+ expect(store.get(isCompanyEnrichmentFetchInFlightState.atom)).toBe(true);
+ });
+});
diff --git a/packages/twenty-front/src/modules/onboarding/utils/getAttendeeName.ts b/packages/twenty-front/src/modules/onboarding/utils/getAttendeeName.ts
new file mode 100644
index 0000000000..6097eac3c1
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/utils/getAttendeeName.ts
@@ -0,0 +1,11 @@
+import { isNonEmptyString } from '@sniptt/guards';
+
+export const getAttendeeName = (
+ nameParts:
+ | { firstName?: string | null; lastName?: string | null }
+ | null
+ | undefined,
+) =>
+ [nameParts?.firstName, nameParts?.lastName]
+ .filter(isNonEmptyString)
+ .join(' ');
diff --git a/packages/twenty-front/src/modules/onboarding/utils/getHasAdvancedPastBookCallStep.ts b/packages/twenty-front/src/modules/onboarding/utils/getHasAdvancedPastBookCallStep.ts
new file mode 100644
index 0000000000..8410405691
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/utils/getHasAdvancedPastBookCallStep.ts
@@ -0,0 +1,7 @@
+import { OnboardingStatus } from '~/generated-metadata/graphql';
+
+export const getHasAdvancedPastBookCallStep = (
+ onboardingStatus: OnboardingStatus | null | undefined,
+) =>
+ onboardingStatus === OnboardingStatus.PLAN_REQUIRED ||
+ onboardingStatus === OnboardingStatus.COMPLETED;
diff --git a/packages/twenty-front/src/modules/onboarding/utils/getIsBookCallOnboardingStepPending.ts b/packages/twenty-front/src/modules/onboarding/utils/getIsBookCallOnboardingStepPending.ts
new file mode 100644
index 0000000000..a0e7af8470
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/utils/getIsBookCallOnboardingStepPending.ts
@@ -0,0 +1,7 @@
+import { type CurrentUser } from '@/auth/states/currentUserState';
+import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
+
+export const getIsBookCallOnboardingStepPending = (
+ currentUser: Pick | null,
+) =>
+ currentUser?.userVars?.[ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY] === true;
diff --git a/packages/twenty-front/src/modules/onboarding/utils/setIsBookCallOnboardingStepPending.ts b/packages/twenty-front/src/modules/onboarding/utils/setIsBookCallOnboardingStepPending.ts
new file mode 100644
index 0000000000..18d8e6b571
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/utils/setIsBookCallOnboardingStepPending.ts
@@ -0,0 +1,21 @@
+import { type CurrentUser } from '@/auth/states/currentUserState';
+import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
+import { isDefined } from 'twenty-shared/utils';
+
+export const setIsBookCallOnboardingStepPending = (
+ currentUser: CurrentUser | null,
+ isBookCallOnboardingStepPending: boolean,
+): CurrentUser | null => {
+ if (!isDefined(currentUser)) {
+ return currentUser;
+ }
+
+ return {
+ ...currentUser,
+ userVars: {
+ ...currentUser.userVars,
+ [ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]:
+ isBookCallOnboardingStepPending,
+ },
+ };
+};
diff --git a/packages/twenty-front/src/modules/onboarding/utils/waitForCompanyEnrichmentSettlement.ts b/packages/twenty-front/src/modules/onboarding/utils/waitForCompanyEnrichmentSettlement.ts
new file mode 100644
index 0000000000..f7981ca0f5
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/utils/waitForCompanyEnrichmentSettlement.ts
@@ -0,0 +1,66 @@
+import type { Store } from 'jotai/vanilla/store';
+import { isDefined } from 'twenty-shared/utils';
+
+import { COMPANY_ENRICHMENT_SETTLEMENT_TIMEOUT_MS } from '@/onboarding/constants/CompanyEnrichmentSettlementTimeoutMs';
+import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
+import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
+import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
+
+const COMPANY_ENRICHMENT_SETTLEMENT_ATOMS = [
+ companyEnrichmentState.atom,
+ hasAttemptedCompanyEnrichmentFetchState.atom,
+ isCompanyEnrichmentFetchInFlightState.atom,
+];
+
+export const waitForCompanyEnrichmentSettlement = ({
+ store,
+ timeoutMs = COMPANY_ENRICHMENT_SETTLEMENT_TIMEOUT_MS,
+}: {
+ store: Store;
+ timeoutMs?: number;
+}): Promise =>
+ new Promise((resolve) => {
+ const hasAnswer = () =>
+ isDefined(store.get(companyEnrichmentState.atom)) ||
+ (store.get(hasAttemptedCompanyEnrichmentFetchState.atom) &&
+ !store.get(isCompanyEnrichmentFetchInFlightState.atom));
+
+ if (hasAnswer()) {
+ resolve();
+
+ return;
+ }
+
+ const unsubscribes: (() => void)[] = [];
+ let timeout: ReturnType | undefined;
+ let isSettled = false;
+
+ const unsubscribeAll = () => {
+ for (const unsubscribe of unsubscribes) {
+ unsubscribe();
+ }
+ };
+
+ const settle = () => {
+ if (isSettled) {
+ return;
+ }
+
+ isSettled = true;
+ clearTimeout(timeout);
+ unsubscribeAll();
+ resolve();
+ };
+
+ const settleWhenAnswered = () => {
+ if (hasAnswer()) {
+ settle();
+ }
+ };
+
+ timeout = setTimeout(settle, timeoutMs);
+
+ for (const atom of COMPANY_ENRICHMENT_SETTLEMENT_ATOMS) {
+ unsubscribes.push(store.sub(atom, settleWhenAnswered));
+ }
+ });
diff --git a/packages/twenty-front/src/pages/onboarding/BookCall.tsx b/packages/twenty-front/src/pages/onboarding/BookCall.tsx
index fd7e0f8b49..f3c61e10e6 100644
--- a/packages/twenty-front/src/pages/onboarding/BookCall.tsx
+++ b/packages/twenty-front/src/pages/onboarding/BookCall.tsx
@@ -1,29 +1,34 @@
-import Cal from '@calcom/embed-react';
-import { useContext } from 'react';
-import { Link } from 'react-router-dom';
+import { isNonEmptyString } from '@sniptt/guards';
+import { Link, Navigate } from 'react-router-dom';
-import { currentUserState } from '@/auth/states/currentUserState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
-import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
+import { BookCallEmbed } from '@/onboarding/components/BookCallEmbed';
+import { BookCallOnboardingStepActions } from '@/onboarding/components/BookCallOnboardingStepActions';
+import { OnboardingStepAnimatedItem } from '@/onboarding/components/OnboardingStepAnimatedItem';
+import { StyledOnboardingStepHeading } from '@/onboarding/components/StyledOnboardingStepHeading';
+import { StyledOnboardingStepPage } from '@/onboarding/components/StyledOnboardingStepPage';
+import { StyledOnboardingStepSubtitle } from '@/onboarding/components/StyledOnboardingStepSubtitle';
+import { StyledOnboardingStepTitle } from '@/onboarding/components/StyledOnboardingStepTitle';
+import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
-import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppPath } from 'twenty-shared/types';
import { IconChevronLeft } from 'twenty-ui/icon';
import { LightButton } from 'twenty-ui/input';
-import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
-import { useIsMobile } from 'twenty-ui/utilities';
+import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
+import { OnboardingStatus } from '~/generated-metadata/graphql';
-const StyledPage = styled.div`
- display: flex;
- flex: 1;
- flex-direction: column;
- min-height: 0;
- width: 100%;
+const StyledPage = styled(StyledOnboardingStepPage)`
+ gap: ${themeCssVariables.spacing[5]};
+ padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[8]};
+
+ @media (max-width: ${MOBILE_VIEWPORT}px) {
+ padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[4]};
+ }
`;
-const StyledContent = styled.div`
- align-items: center;
+const StyledEmbed = styled(OnboardingStepAnimatedItem)`
display: flex;
flex: 1;
justify-content: center;
@@ -41,37 +46,49 @@ const StyledFooter = styled.div`
`;
export const BookCall = () => {
- const { colorScheme } = useContext(ThemeContext);
-
const { t } = useLingui();
+ const onboardingStatus = useOnboardingStatus();
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
- const currentUser = useAtomStateValue(currentUserState);
- const isMobile = useIsMobile();
+ const isOnboardingStep = onboardingStatus === OnboardingStatus.BOOK_CALL;
+ const hasBookingPage = isNonEmptyString(calendarBookingPageId);
+
+ // Never redirect out of the step itself: the page-change effect routes
+ // BOOK_CALL back here, so the two would bounce off each other.
+ if (!hasBookingPage && !isOnboardingStep) {
+ return ;
+ }
return (
-
-
-
-
-
-
-
-
-
-
+
+
+ {t`Talk to our team`}
+
+
+
+ {t`Book a 30-minute call and we'll help you get your workspace production-ready.`}
+
+
+
+
+ {hasBookingPage && (
+
+
+
+ )}
+
+
+
+ {isOnboardingStep ? (
+
+ ) : (
+
+
+
+ )}
+
+
);
};
diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx
index c3ec318804..d1aa132a83 100644
--- a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx
+++ b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx
@@ -19,6 +19,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { Controller } from 'react-hook-form';
import { isDefined } from 'twenty-shared/utils';
import { IconX } from 'twenty-ui/icon';
+import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -124,6 +125,7 @@ export const InviteTeam = () => {
(isSubmitting || isNavigating ? : null)}
disabled={!isValid || isSubmitting || isNavigating}
onClick={handleSubmit(onSubmit)}
fullWidth
diff --git a/packages/twenty-front/src/pages/onboarding/__tests__/BookCall.test.tsx b/packages/twenty-front/src/pages/onboarding/__tests__/BookCall.test.tsx
new file mode 100644
index 0000000000..aace7f9c97
--- /dev/null
+++ b/packages/twenty-front/src/pages/onboarding/__tests__/BookCall.test.tsx
@@ -0,0 +1,90 @@
+import { i18n } from '@lingui/core';
+import { I18nProvider } from '@lingui/react';
+import { render, screen } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { SOURCE_LOCALE } from 'twenty-shared/translations';
+import { AppPath } from 'twenty-shared/types';
+import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
+
+import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
+import { BookCall } from '~/pages/onboarding/BookCall';
+import { OnboardingStatus } from '~/generated-metadata/graphql';
+
+const mockOnboardingStatus = jest.fn();
+
+jest.mock('@/onboarding/hooks/useOnboardingStatus', () => ({
+ useOnboardingStatus: () => mockOnboardingStatus(),
+}));
+
+jest.mock('@/onboarding/components/BookCallEmbed', () => ({
+ BookCallEmbed: ({
+ calendarBookingPageId,
+ }: {
+ calendarBookingPageId: string;
+ }) => {calendarBookingPageId}
,
+}));
+
+jest.mock('@/onboarding/components/BookCallOnboardingStepActions', () => ({
+ BookCallOnboardingStepActions: () => Skip
,
+}));
+
+dynamicActivate(SOURCE_LOCALE);
+
+const renderPage = () =>
+ render(
+
+
+
+
+ } />
+ Choose your plan}
+ />
+
+
+
+ ,
+ );
+
+describe('BookCall', () => {
+ beforeEach(() => {
+ resetJotaiStore();
+ jest.clearAllMocks();
+ mockOnboardingStatus.mockReturnValue(OnboardingStatus.BOOK_CALL);
+ });
+
+ it('should render the step heading alongside the embed', () => {
+ jotaiStore.set(calendarBookingPageIdState.atom, 'team/twenty/talk-to-us');
+
+ renderPage();
+
+ expect(screen.getByText('Talk to our team')).toBeInTheDocument();
+ expect(screen.getByTestId('book-call-embed')).toHaveTextContent(
+ 'team/twenty/talk-to-us',
+ );
+ });
+
+ it('should redirect to the plan step rather than render an empty embed', () => {
+ mockOnboardingStatus.mockReturnValue(OnboardingStatus.PLAN_REQUIRED);
+
+ renderPage();
+
+ expect(screen.getByText('Choose your plan')).toBeInTheDocument();
+ expect(screen.queryByTestId('book-call-embed')).not.toBeInTheDocument();
+ });
+
+ it('should stay on the step without an embed rather than bounce against the redirect engine', () => {
+ renderPage();
+
+ expect(screen.getByText('Talk to our team')).toBeInTheDocument();
+ expect(screen.getByText('Skip')).toBeInTheDocument();
+ expect(screen.queryByText('Choose your plan')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('book-call-embed')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts
index fdeb4e6d88..604c203345 100644
--- a/packages/twenty-front/src/testing/mock-data/config.ts
+++ b/packages/twenty-front/src/testing/mock-data/config.ts
@@ -68,4 +68,6 @@ export const mockedClientConfig: ClientConfig = {
isClickHouseConfigured: false,
isWorkspaceSchemaDDLLocked: false,
isOnboardingAiChatEnabled: false,
+ isBookCallOnboardingStepEnabled: false,
+ isCompanyEnrichmentEnabled: false,
};
diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example
index 881eaa1bfc..66182e06ac 100644
--- a/packages/twenty-server/.env.example
+++ b/packages/twenty-server/.env.example
@@ -89,10 +89,20 @@ FRONTEND_URL=http://localhost:3001
# ———————— ONBOARDING AI CHAT ————————
# Opens an AI chat at the end of onboarding to help set up the workspace.
# IS_ONBOARDING_AI_CHAT_ENABLED=true
-# Enriches the new workspace with its own company data, used as chat context.
+
+# ———————— ENRICHMENT ————————
+# Enriches the new workspace with its own company data, used as AI chat context
+# and to qualify leads for the book-a-call onboarding step.
# Active as soon as the API key is set.
# PEOPLE_DATA_LABS_API_KEY=
+# ———————— BOOK A CALL ————————
+# Shows a Cal.com booking step during onboarding to enriched companies with at
+# least this many employees. Requires enrichment and CALENDAR_BOOKING_PAGE_ID.
+# Both are opt-in: leave the employee count unset to disable the step.
+# CALENDAR_BOOKING_PAGE_ID=team/twenty/talk-to-us
+# ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT=20
+
# ———————— AI ————————
# API keys for built-in providers (also editable from Admin Panel > Config Variables):
# OPENAI_API_KEY=
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts
index 756b3eaef9..e3b2edb8fc 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts
@@ -105,6 +105,8 @@ describe('ClientConfigController', () => {
isImapSmtpCaldavEnabled: false,
isEmailingDomainInDemoMode: false,
calendarBookingPageId: undefined,
+ isBookCallOnboardingStepEnabled: false,
+ isCompanyEnrichmentEnabled: false,
isTwoFactorAuthenticationEnabled: false,
allowRequestsToTwentyIcons: true,
isCloudflareIntegrationEnabled: false,
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
index 7e5bbf9387..8b2ae617b3 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
@@ -339,6 +339,12 @@ export class ClientConfig {
@Field(() => String, { nullable: true })
calendarBookingPageId?: string;
+ @Field(() => Boolean)
+ isBookCallOnboardingStepEnabled: boolean;
+
+ @Field(() => Boolean)
+ isCompanyEnrichmentEnabled: boolean;
+
@Field(() => Boolean)
isCloudflareIntegrationEnabled: boolean;
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
index 1cdeebd3d0..4cd4da967f 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
@@ -106,6 +106,7 @@ describe('ClientConfigService', () => {
IS_CONFIG_VARIABLES_IN_DB_ENABLED: false,
IS_IMAP_SMTP_CALDAV_ENABLED: false,
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
+ ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 50,
CLOUDFLARE_API_KEY: undefined,
CLOUDFLARE_ZONE_ID: undefined,
ALLOW_REQUESTS_TO_TWENTY_ICONS: false,
@@ -190,6 +191,8 @@ describe('ClientConfigService', () => {
isEmailingDomainInDemoMode: false,
allowRequestsToTwentyIcons: false,
calendarBookingPageId: 'team/twenty/talk-to-us',
+ isBookCallOnboardingStepEnabled: true,
+ isCompanyEnrichmentEnabled: false,
isCloudflareIntegrationEnabled: false,
isClickHouseConfigured: false,
isOnboardingAiChatEnabled: false,
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
index 09f3a5b164..4d7dd535d8 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
+import { readIsCompanyEnrichmentEnabled } from 'src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util';
+import { readBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
@@ -47,6 +49,12 @@ export class ClientConfigService {
const calendarBookingPageId = this.twentyConfigService.get(
'CALENDAR_BOOKING_PAGE_ID',
);
+ const isBookCallOnboardingStepEnabled = isDefined(
+ readBookCallStepMinEmployeeCount(this.twentyConfigService),
+ );
+ const isCompanyEnrichmentEnabled = readIsCompanyEnrichmentEnabled(
+ this.twentyConfigService,
+ );
const isEmailingDomainInDemoMode =
this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER') ===
@@ -284,6 +292,8 @@ export class ClientConfigService {
calendarBookingPageId: isNonEmptyString(calendarBookingPageId)
? calendarBookingPageId
: undefined,
+ isBookCallOnboardingStepEnabled,
+ isCompanyEnrichmentEnabled,
isCloudflareIntegrationEnabled: this.isCloudflareIntegrationEnabled(),
isClickHouseConfigured: !!this.twentyConfigService.get('CLICKHOUSE_URL'),
isWorkspaceSchemaDDLLocked: this.twentyConfigService.get(
diff --git a/packages/twenty-server/src/engine/core-modules/company-enrichment/company-enrichment.module.ts b/packages/twenty-server/src/engine/core-modules/company-enrichment/company-enrichment.module.ts
index 4b72ae9007..89ebd88068 100644
--- a/packages/twenty-server/src/engine/core-modules/company-enrichment/company-enrichment.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/company-enrichment/company-enrichment.module.ts
@@ -4,6 +4,7 @@ import { CompanyEnrichmentResolver } from 'src/engine/core-modules/company-enric
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
import { PeopleDataLabsCompanyClientService } from 'src/engine/core-modules/company-enrichment/services/people-data-labs-company-client.service';
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
+import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
@@ -11,6 +12,7 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user
@Module({
imports: [
KeyValuePairModule,
+ OnboardingModule,
SecureHttpClientModule,
ThrottlerModule,
UserWorkspaceModule,
diff --git a/packages/twenty-server/src/engine/core-modules/company-enrichment/dtos/workspace-company-enrichment-result.dto.ts b/packages/twenty-server/src/engine/core-modules/company-enrichment/dtos/workspace-company-enrichment-result.dto.ts
index 3f174239e9..80dc0610f8 100644
--- a/packages/twenty-server/src/engine/core-modules/company-enrichment/dtos/workspace-company-enrichment-result.dto.ts
+++ b/packages/twenty-server/src/engine/core-modules/company-enrichment/dtos/workspace-company-enrichment-result.dto.ts
@@ -12,4 +12,7 @@ export class WorkspaceCompanyEnrichmentResultDTO {
@Field(() => GraphQLJSON, { nullable: true })
enrichment: WorkspaceCompanyEnrichment | null;
+
+ @Field(() => Boolean)
+ isBookCallOnboardingStepPending: boolean;
}
diff --git a/packages/twenty-server/src/engine/core-modules/company-enrichment/resolvers/__tests__/company-enrichment.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/company-enrichment/resolvers/__tests__/company-enrichment.resolver.spec.ts
new file mode 100644
index 0000000000..2db980fc37
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/company-enrichment/resolvers/__tests__/company-enrichment.resolver.spec.ts
@@ -0,0 +1,108 @@
+import { Test, type TestingModule } from '@nestjs/testing';
+
+import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
+import { CompanyEnrichmentResolver } from 'src/engine/core-modules/company-enrichment/resolvers/company-enrichment.resolver';
+import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
+import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
+import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+
+describe('CompanyEnrichmentResolver', () => {
+ let resolver: CompanyEnrichmentResolver;
+ let companyEnrichmentService: { enrichCompanyForWorkspaceCreator: jest.Mock };
+ let onboardingService: {
+ setOnboardingBookCallPendingIfQualified: jest.Mock;
+ isOnboardingBookCallPending: jest.Mock;
+ };
+
+ const user = { id: 'user-id', email: 'foo@acme.com' } as AuthContextUser;
+ const workspace = { id: 'workspace-id' } as WorkspaceEntity;
+
+ beforeEach(async () => {
+ companyEnrichmentService = {
+ enrichCompanyForWorkspaceCreator: jest.fn(),
+ };
+ onboardingService = {
+ setOnboardingBookCallPendingIfQualified: jest.fn(),
+ isOnboardingBookCallPending: jest.fn().mockResolvedValue(false),
+ };
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ CompanyEnrichmentResolver,
+ {
+ provide: CompanyEnrichmentService,
+ useValue: companyEnrichmentService,
+ },
+ {
+ provide: OnboardingService,
+ useValue: onboardingService,
+ },
+ ],
+ }).compile();
+
+ resolver = module.get(CompanyEnrichmentResolver);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should hand the enriched employee count to the book-call qualification on a match', async () => {
+ companyEnrichmentService.enrichCompanyForWorkspaceCreator.mockResolvedValue(
+ {
+ outcome: 'matched',
+ enrichment: { domain: 'acme.com', employeeCount: 320 },
+ },
+ );
+
+ const result = await resolver.enrichWorkspaceCompany(user, workspace);
+
+ expect(result.outcome).toBe('matched');
+ expect(
+ onboardingService.setOnboardingBookCallPendingIfQualified,
+ ).toHaveBeenCalledWith({
+ userId: user.id,
+ workspaceId: workspace.id,
+ employeeCount: 320,
+ });
+ });
+
+ it.each(['unavailable', 'transientError'])(
+ 'should not qualify for the book-call step on outcome %s',
+ async (outcome) => {
+ companyEnrichmentService.enrichCompanyForWorkspaceCreator.mockResolvedValue(
+ { outcome, enrichment: null },
+ );
+
+ await resolver.enrichWorkspaceCompany(user, workspace);
+
+ expect(
+ onboardingService.setOnboardingBookCallPendingIfQualified,
+ ).not.toHaveBeenCalled();
+ },
+ );
+
+ it('should report the stored pending flag rather than whether this call flagged it', async () => {
+ companyEnrichmentService.enrichCompanyForWorkspaceCreator.mockResolvedValue(
+ { outcome: 'transientError', enrichment: null },
+ );
+ onboardingService.isOnboardingBookCallPending.mockResolvedValue(true);
+
+ const result = await resolver.enrichWorkspaceCompany(user, workspace);
+
+ expect(result.isBookCallOnboardingStepPending).toBe(true);
+ });
+
+ it('should report no pending step when the user has none', async () => {
+ companyEnrichmentService.enrichCompanyForWorkspaceCreator.mockResolvedValue(
+ {
+ outcome: 'matched',
+ enrichment: { domain: 'acme.com', employeeCount: 2 },
+ },
+ );
+
+ const result = await resolver.enrichWorkspaceCompany(user, workspace);
+
+ expect(result.isBookCallOnboardingStepPending).toBe(false);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/company-enrichment/resolvers/company-enrichment.resolver.ts b/packages/twenty-server/src/engine/core-modules/company-enrichment/resolvers/company-enrichment.resolver.ts
index ebe72ab732..a47e94a1bf 100644
--- a/packages/twenty-server/src/engine/core-modules/company-enrichment/resolvers/company-enrichment.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/company-enrichment/resolvers/company-enrichment.resolver.ts
@@ -1,14 +1,14 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Mutation } from '@nestjs/graphql';
-import { type WorkspaceCompanyEnrichmentResult } from 'twenty-shared/workspace';
-
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceCompanyEnrichmentResultDTO } from 'src/engine/core-modules/company-enrichment/dtos/workspace-company-enrichment-result.dto';
+import { WorkspaceCompanyEnrichmentOutcome } from 'src/engine/core-modules/company-enrichment/enums/workspace-company-enrichment-outcome.enum';
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
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 { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.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';
@@ -23,6 +23,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
export class CompanyEnrichmentResolver {
constructor(
private readonly companyEnrichmentService: CompanyEnrichmentService,
+ private readonly onboardingService: OnboardingService,
) {}
@Mutation(() => WorkspaceCompanyEnrichmentResultDTO)
@@ -30,11 +31,32 @@ export class CompanyEnrichmentResolver {
async enrichWorkspaceCompany(
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
- ): Promise {
- return this.companyEnrichmentService.enrichCompanyForWorkspaceCreator({
- userId: user.id,
- email: user.email,
- workspaceId: workspace.id,
- });
+ ): Promise {
+ const enrichmentResult =
+ await this.companyEnrichmentService.enrichCompanyForWorkspaceCreator({
+ userId: user.id,
+ email: user.email,
+ workspaceId: workspace.id,
+ });
+
+ if (enrichmentResult.outcome === 'matched') {
+ await this.onboardingService.setOnboardingBookCallPendingIfQualified({
+ userId: user.id,
+ workspaceId: workspace.id,
+ employeeCount: enrichmentResult.enrichment.employeeCount,
+ });
+ }
+
+ const isBookCallOnboardingStepPending =
+ await this.onboardingService.isOnboardingBookCallPending({
+ userId: user.id,
+ workspaceId: workspace.id,
+ });
+
+ return {
+ ...enrichmentResult,
+ outcome: WorkspaceCompanyEnrichmentOutcome[enrichmentResult.outcome],
+ isBookCallOnboardingStepPending,
+ };
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/company-enrichment/services/__tests__/company-enrichment.service.spec.ts b/packages/twenty-server/src/engine/core-modules/company-enrichment/services/__tests__/company-enrichment.service.spec.ts
index f958102b50..ff2b7f7e43 100644
--- a/packages/twenty-server/src/engine/core-modules/company-enrichment/services/__tests__/company-enrichment.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/company-enrichment/services/__tests__/company-enrichment.service.spec.ts
@@ -22,7 +22,7 @@ describe('CompanyEnrichmentService', () => {
};
let throttlerService: { tokenBucketThrottleOrThrow: jest.Mock };
let keyValuePairService: { set: jest.Mock };
- let twentyConfigService: { get: jest.Mock };
+ let configValues: Record;
const workspaceId = 'workspace-id';
const creatorUserId = 'creator-user-id';
@@ -41,8 +41,9 @@ describe('CompanyEnrichmentService', () => {
};
throttlerService = { tokenBucketThrottleOrThrow: jest.fn() };
keyValuePairService = { set: jest.fn() };
- twentyConfigService = {
- get: jest.fn().mockReturnValue(true),
+ configValues = {
+ IS_ONBOARDING_AI_CHAT_ENABLED: true,
+ PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
};
const module: TestingModule = await Test.createTestingModule({
@@ -62,7 +63,7 @@ describe('CompanyEnrichmentService', () => {
},
{
provide: TwentyConfigService,
- useValue: twentyConfigService,
+ useValue: { get: (key: string) => configValues[key] },
},
{
provide: KeyValuePairService,
@@ -204,27 +205,6 @@ describe('CompanyEnrichmentService', () => {
);
});
- it('should return unavailable without any lookup when onboarding AI chat is off', async () => {
- twentyConfigService.get.mockReturnValue(false);
-
- const result = await service.enrichCompanyForWorkspaceCreator({
- userId: creatorUserId,
- email: 'foo@acme.com',
- workspaceId,
- });
-
- expect(result).toEqual({ outcome: 'unavailable', enrichment: null });
- expect(twentyConfigService.get).toHaveBeenCalledWith(
- 'IS_ONBOARDING_AI_CHAT_ENABLED',
- );
- expect(userWorkspaceService.isWorkspaceCreator).not.toHaveBeenCalled();
- expect(throttlerService.tokenBucketThrottleOrThrow).not.toHaveBeenCalled();
- expect(
- peopleDataLabsCompanyClientService.enrichCompanyByDomain,
- ).not.toHaveBeenCalled();
- expect(keyValuePairService.set).not.toHaveBeenCalled();
- });
-
it('should not consume throttle tokens when the feature is disabled', async () => {
peopleDataLabsCompanyClientService.isEnabled.mockReturnValue(false);
@@ -336,6 +316,90 @@ describe('CompanyEnrichmentService', () => {
});
});
+ it('should not call the client when no api key is configured', async () => {
+ configValues = { IS_ONBOARDING_AI_CHAT_ENABLED: true };
+
+ const result = await service.enrichCompanyForWorkspaceCreator({
+ userId: creatorUserId,
+ email: 'foo@acme.com',
+ workspaceId,
+ });
+
+ expect(result.outcome).toBe('unavailable');
+ expect(
+ peopleDataLabsCompanyClientService.enrichCompanyByDomain,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('should not call the client when no consumer of the enrichment is configured', async () => {
+ configValues = {
+ IS_ONBOARDING_AI_CHAT_ENABLED: false,
+ PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
+ };
+
+ const result = await service.enrichCompanyForWorkspaceCreator({
+ userId: creatorUserId,
+ email: 'foo@acme.com',
+ workspaceId,
+ });
+
+ expect(result).toEqual({ outcome: 'unavailable', enrichment: null });
+ expect(throttlerService.tokenBucketThrottleOrThrow).not.toHaveBeenCalled();
+ expect(
+ peopleDataLabsCompanyClientService.enrichCompanyByDomain,
+ ).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ { CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us' },
+ { ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 50 },
+ {
+ CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
+ ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 0,
+ },
+ ])(
+ 'should not call the client for a half-configured book-call step (%j)',
+ async (bookCallConfig) => {
+ configValues = {
+ IS_ONBOARDING_AI_CHAT_ENABLED: false,
+ PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
+ ...bookCallConfig,
+ };
+
+ await service.enrichCompanyForWorkspaceCreator({
+ userId: creatorUserId,
+ email: 'foo@acme.com',
+ workspaceId,
+ });
+
+ expect(
+ peopleDataLabsCompanyClientService.enrichCompanyByDomain,
+ ).not.toHaveBeenCalled();
+ },
+ );
+
+ it('should enrich for the book-call step alone when the ai chat is disabled', async () => {
+ configValues = {
+ IS_ONBOARDING_AI_CHAT_ENABLED: false,
+ CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
+ ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 50,
+ PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
+ };
+ peopleDataLabsCompanyClientService.enrichCompanyByDomain.mockResolvedValue({
+ outcome: 'matched',
+ data: { name: 'Acme Inc', employee_count: 320 },
+ });
+
+ const result = await service.enrichCompanyForWorkspaceCreator({
+ userId: creatorUserId,
+ email: 'foo@acme.com',
+ workspaceId,
+ });
+
+ expect(result.outcome).toBe('matched');
+ expect(result.enrichment).toMatchObject({ employeeCount: 320 });
+ });
+
it('should rethrow non throttler errors from the throttler', async () => {
throttlerService.tokenBucketThrottleOrThrow.mockRejectedValue(
new Error('redis down'),
diff --git a/packages/twenty-server/src/engine/core-modules/company-enrichment/services/company-enrichment.service.ts b/packages/twenty-server/src/engine/core-modules/company-enrichment/services/company-enrichment.service.ts
index 6cd6eaf4db..80558acd06 100644
--- a/packages/twenty-server/src/engine/core-modules/company-enrichment/services/company-enrichment.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/company-enrichment/services/company-enrichment.service.ts
@@ -15,6 +15,7 @@ import { type PeopleDataLabsCompanyEnrichResult } from 'src/engine/core-modules/
import { toWorkspaceCompanyEnrichment } from 'src/engine/core-modules/company-enrichment/utils/to-workspace-company-enrichment.util';
import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service';
+import { readIsCompanyEnrichmentEnabled } from 'src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util';
import {
ThrottlerException,
ThrottlerExceptionCode,
@@ -47,8 +48,7 @@ export class CompanyEnrichmentService {
email: string;
workspaceId: string;
}): Promise {
- // The enrichment only feeds the AI-chat workspace setup, so it is pointless without it.
- if (!this.twentyConfigService.get('IS_ONBOARDING_AI_CHAT_ENABLED')) {
+ if (!this.hasEnrichmentConsumer()) {
return { outcome: 'unavailable', enrichment: null };
}
@@ -104,16 +104,16 @@ export class CompanyEnrichmentService {
// 'skipped' means the feature is disabled (no API key); don't persist the domain in that case.
if (result.outcome !== 'skipped') {
- await this.recordEnrichmentAttempt({
- workspaceId,
- domain,
- result,
- });
+ await this.recordEnrichmentAttempt({ workspaceId, domain, result });
}
return enrichmentResult;
}
+ private hasEnrichmentConsumer(): boolean {
+ return readIsCompanyEnrichmentEnabled(this.twentyConfigService);
+ }
+
private resolveEnrichmentResult({
result,
workspaceId,
diff --git a/packages/twenty-server/src/engine/core-modules/company-enrichment/utils/__tests__/read-is-company-enrichment-enabled.util.spec.ts b/packages/twenty-server/src/engine/core-modules/company-enrichment/utils/__tests__/read-is-company-enrichment-enabled.util.spec.ts
new file mode 100644
index 0000000000..902a464d6c
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/company-enrichment/utils/__tests__/read-is-company-enrichment-enabled.util.spec.ts
@@ -0,0 +1,71 @@
+import { readIsCompanyEnrichmentEnabled } from 'src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util';
+import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+
+const buildConfigService = (values: Record) =>
+ ({
+ get: (key: string) => values[key],
+ }) as TwentyConfigService;
+
+describe('readIsCompanyEnrichmentEnabled', () => {
+ it('should be enabled with the AI chat consumer and an api key', () => {
+ expect(
+ readIsCompanyEnrichmentEnabled(
+ buildConfigService({
+ IS_ONBOARDING_AI_CHAT_ENABLED: true,
+ PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it('should be enabled with the book-call consumer and an api key', () => {
+ expect(
+ readIsCompanyEnrichmentEnabled(
+ buildConfigService({
+ IS_ONBOARDING_AI_CHAT_ENABLED: false,
+ CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
+ ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 20,
+ PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it('should be disabled without any consumer', () => {
+ expect(
+ readIsCompanyEnrichmentEnabled(
+ buildConfigService({
+ IS_ONBOARDING_AI_CHAT_ENABLED: false,
+ PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
+ }),
+ ),
+ ).toBe(false);
+ });
+
+ it('should be disabled without an api key even when a consumer is configured', () => {
+ expect(
+ readIsCompanyEnrichmentEnabled(
+ buildConfigService({
+ IS_ONBOARDING_AI_CHAT_ENABLED: true,
+ PEOPLE_DATA_LABS_API_KEY: undefined,
+ }),
+ ),
+ ).toBe(false);
+ });
+
+ it('should be disabled with neither a consumer nor an api key', () => {
+ expect(readIsCompanyEnrichmentEnabled(buildConfigService({}))).toBe(false);
+ });
+
+ it('should not treat an unconfigured book-call threshold as a consumer', () => {
+ expect(
+ readIsCompanyEnrichmentEnabled(
+ buildConfigService({
+ IS_ONBOARDING_AI_CHAT_ENABLED: false,
+ CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
+ PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
+ }),
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util.ts b/packages/twenty-server/src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util.ts
new file mode 100644
index 0000000000..5b1aaddf23
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util.ts
@@ -0,0 +1,20 @@
+import { isNonEmptyString } from '@sniptt/guards';
+import { isDefined } from 'twenty-shared/utils';
+
+import { readBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+
+// Enrichment is only worth running when something consumes it and the provider
+// can actually answer, so callers can skip the round-trip entirely.
+export const readIsCompanyEnrichmentEnabled = (
+ twentyConfigService: TwentyConfigService,
+): boolean => {
+ const hasConsumer =
+ twentyConfigService.get('IS_ONBOARDING_AI_CHAT_ENABLED') ||
+ isDefined(readBookCallStepMinEmployeeCount(twentyConfigService));
+
+ return (
+ hasConsumer &&
+ isNonEmptyString(twentyConfigService.get('PEOPLE_DATA_LABS_API_KEY'))
+ );
+};
diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
index b5b30dd58a..248ec5faba 100644
--- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
@@ -121,6 +121,49 @@ export class KeyValuePairService<
});
}
+ // Returns false when a row already exists, so callers can use this as a
+ // single-winner claim between concurrent requests.
+ async setIfNotExists(
+ {
+ userId,
+ workspaceId,
+ applicationId,
+ key,
+ value,
+ type,
+ }: {
+ userId?: string | null;
+ workspaceId?: string | null;
+ applicationId?: string | null;
+ key: Extract;
+ value: KeyValueTypesMap[K];
+ type: KeyValuePairType;
+ },
+ queryRunner?: QueryRunner,
+ ): Promise {
+ const keyValuePairRepository = queryRunner
+ ? queryRunner.manager.getRepository(KeyValuePairEntity)
+ : this.keyValuePairRepository;
+
+ const insertResult = await keyValuePairRepository
+ .createQueryBuilder()
+ .insert()
+ .into(KeyValuePairEntity)
+ .values({
+ userId: userId ?? null,
+ workspaceId: workspaceId ?? null,
+ applicationId: applicationId ?? null,
+ key,
+ value,
+ type,
+ })
+ .orIgnore()
+ .returning('id')
+ .execute();
+
+ return insertResult.raw.length > 0;
+ }
+
async delete(
{
userId,
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-status.enum.ts b/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-status.enum.ts
index 4cc31ead99..39ae7a0798 100644
--- a/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-status.enum.ts
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-status.enum.ts
@@ -5,5 +5,6 @@ export enum OnboardingStatus {
SYNC_EMAIL = 'SYNC_EMAIL',
APPS_INSTALLATION = 'APPS_INSTALLATION',
INVITE_TEAM = 'INVITE_TEAM',
+ BOOK_CALL = 'BOOK_CALL',
COMPLETED = 'COMPLETED',
}
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts
index 9a21defc5b..8b5d1226d8 100644
--- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts
@@ -56,6 +56,21 @@ export class OnboardingResolver {
return { success: true };
}
+ @Mutation(() => OnboardingStepSuccessDTO)
+ @UseGuards(NoPermissionGuard)
+ async completeBookCallOnboardingStep(
+ @AuthUser() user: AuthContextUser,
+ @AuthWorkspace() workspace: WorkspaceEntity,
+ ): Promise {
+ await this.onboardingService.setOnboardingBookCallPending({
+ userId: user.id,
+ workspaceId: workspace.id,
+ value: false,
+ });
+
+ return { success: true };
+ }
+
@Mutation(() => OnboardingStepSuccessDTO)
@UseGuards(NoPermissionGuard)
async triggerInstallAppsOnboardingStep(
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
index 128d5542ab..48397f8a08 100644
--- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
@@ -1,7 +1,8 @@
import { Test, type TestingModule } from '@nestjs/testing';
-import { getRepositoryToken } from '@nestjs/typeorm';
+import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
+import { type DataSource, type QueryRunner, Repository } from 'typeorm';
import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
@@ -9,6 +10,7 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS } from 'src/engine/core-modules/onboarding/constants/onboarding-installable-app-universal-identifiers';
+import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import { INSTALL_ONBOARDING_APPS_JOB_NAME } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
import {
OnboardingService,
@@ -16,21 +18,30 @@ import {
} from 'src/engine/core-modules/onboarding/onboarding.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
+import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
describe('OnboardingService', () => {
let service: OnboardingService;
let userVarsService: UserVarsService;
+ let billingService: BillingService;
let billingCreditService: BillingCreditService;
let twentyConfigService: TwentyConfigService;
let messageQueueService: MessageQueueService;
let userWorkspaceRepository: Repository;
+ let workspaceRepository: Repository;
+ let dataSource: DataSource;
const userId = 'user-id';
const workspaceId = 'workspace-id';
+ const mockQueryRunner = {} as QueryRunner;
+
+ let transactionQueryRunner: QueryRunner | undefined = mockQueryRunner;
beforeEach(async () => {
+ transactionQueryRunner = mockQueryRunner;
+
const module: TestingModule = await Test.createTestingModule({
providers: [
OnboardingService,
@@ -50,7 +61,9 @@ describe('OnboardingService', () => {
provide: UserVarsService,
useValue: {
get: jest.fn(),
+ getAll: jest.fn(),
set: jest.fn(),
+ setIfNotExists: jest.fn().mockResolvedValue(true),
delete: jest.fn(),
},
},
@@ -76,11 +89,23 @@ describe('OnboardingService', () => {
add: jest.fn(),
},
},
+ {
+ provide: getDataSourceToken(),
+ useValue: {
+ transaction: jest.fn((runInTransaction) =>
+ runInTransaction({ queryRunner: transactionQueryRunner }),
+ ),
+ },
+ },
],
}).compile();
service = module.get(OnboardingService);
userVarsService = module.get(UserVarsService);
+ billingService = module.get(BillingService);
+ workspaceRepository = module.get>(
+ getRepositoryToken(WorkspaceEntity),
+ );
billingCreditService =
module.get(BillingCreditService);
twentyConfigService = module.get(TwentyConfigService);
@@ -90,12 +115,107 @@ describe('OnboardingService', () => {
userWorkspaceRepository = module.get>(
getRepositoryToken(UserWorkspaceEntity),
);
+ dataSource = module.get(getDataSourceToken());
});
afterEach(() => {
jest.clearAllMocks();
});
+ describe('getOnboardingStatus', () => {
+ const user = { id: userId } as UserEntity;
+
+ const mockOnboardingState = ({
+ pendingSteps,
+ isPlanRequired,
+ isBookCallStepConfigured = true,
+ }: {
+ pendingSteps: OnboardingStepKeys[];
+ isPlanRequired: boolean;
+ isBookCallStepConfigured?: boolean;
+ }) => {
+ jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue({
+ id: workspaceId,
+ activationStatus: WorkspaceActivationStatus.ACTIVE,
+ } as WorkspaceEntity);
+ jest
+ .spyOn(userVarsService, 'getAll')
+ .mockResolvedValue(
+ new Map(pendingSteps.map((key) => [key, true])) as never,
+ );
+ jest
+ .spyOn(billingService, 'isSubscriptionIncompleteOnboardingStatus')
+ .mockResolvedValue(isPlanRequired);
+ jest
+ .spyOn(twentyConfigService, 'get')
+ .mockImplementation((key: string) => {
+ if (!isBookCallStepConfigured) {
+ return undefined as never;
+ }
+
+ return (
+ key === 'CALENDAR_BOOKING_PAGE_ID' ? 'team/twenty/talk-to-us' : 50
+ ) as never;
+ });
+ };
+
+ it('should return BOOK_CALL when the step is pending and a plan is still required', async () => {
+ mockOnboardingState({
+ pendingSteps: [OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING],
+ isPlanRequired: true,
+ });
+
+ expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
+ OnboardingStatus.BOOK_CALL,
+ );
+ });
+
+ it('should ignore a pending BOOK_CALL once the workspace has a subscription', async () => {
+ mockOnboardingState({
+ pendingSteps: [OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING],
+ isPlanRequired: false,
+ });
+
+ expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
+ OnboardingStatus.COMPLETED,
+ );
+ });
+
+ it('should ignore a pending BOOK_CALL once the booking page is unconfigured', async () => {
+ mockOnboardingState({
+ pendingSteps: [OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING],
+ isPlanRequired: true,
+ isBookCallStepConfigured: false,
+ });
+
+ expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
+ OnboardingStatus.PLAN_REQUIRED,
+ );
+ });
+
+ it('should keep INVITE_TEAM ahead of a pending BOOK_CALL', async () => {
+ mockOnboardingState({
+ pendingSteps: [
+ OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING,
+ OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
+ ],
+ isPlanRequired: true,
+ });
+
+ expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
+ OnboardingStatus.INVITE_TEAM,
+ );
+ });
+
+ it('should return PLAN_REQUIRED when no step is pending and a plan is required', async () => {
+ mockOnboardingState({ pendingSteps: [], isPlanRequired: true });
+
+ expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
+ OnboardingStatus.PLAN_REQUIRED,
+ );
+ });
+ });
+
describe('completeOnboardingConnectAccountStep', () => {
it('should credit the import-contacts reward when the step was claimed by the first workspace user', async () => {
jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
@@ -289,4 +409,355 @@ describe('OnboardingService', () => {
).resolves.not.toThrow();
});
});
+
+ describe('setOnboardingBookCallPendingIfQualified', () => {
+ const mockConfig = ({
+ calendarBookingPageId,
+ minEmployeeCount,
+ }: {
+ calendarBookingPageId?: string;
+ minEmployeeCount?: number;
+ }) => {
+ jest
+ .spyOn(twentyConfigService, 'get')
+ .mockImplementation((key: string) =>
+ key === 'CALENDAR_BOOKING_PAGE_ID'
+ ? calendarBookingPageId
+ : minEmployeeCount,
+ );
+ };
+
+ it('should not offer the step twice', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+ jest.spyOn(userVarsService, 'setIfNotExists').mockResolvedValue(false);
+
+ const isPending = await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ });
+
+ expect(isPending).toBe(false);
+ expect(userVarsService.set).not.toHaveBeenCalled();
+ });
+
+ it('should record the offer so a later enrichment cannot reopen the step', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+
+ const isPending = await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ });
+
+ expect(isPending).toBe(true);
+ expect(userVarsService.setIfNotExists).toHaveBeenCalledWith(
+ {
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_OFFERED,
+ value: true,
+ },
+ mockQueryRunner,
+ );
+ });
+
+ it('should let only one of two concurrent qualifications flag the step', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+ jest
+ .spyOn(userVarsService, 'setIfNotExists')
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false);
+
+ await Promise.all([
+ service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ }),
+ service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ }),
+ ]);
+
+ expect(userVarsService.set).toHaveBeenCalledTimes(1);
+ });
+
+ it('should write the offer and the pending step in a single transaction', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ });
+
+ const [[, offeredQueryRunner]] = jest.mocked(
+ userVarsService.setIfNotExists,
+ ).mock.calls;
+ const [[, pendingQueryRunner]] = jest.mocked(userVarsService.set).mock
+ .calls;
+
+ expect(offeredQueryRunner).toBe(mockQueryRunner);
+ expect(pendingQueryRunner).toBe(mockQueryRunner);
+ });
+
+ it('should not record the offer when flagging the step fails', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+ jest
+ .spyOn(dataSource, 'transaction')
+ .mockRejectedValue(new Error('user vars down'));
+
+ await expect(
+ service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ }),
+ ).resolves.not.toThrow();
+
+ expect(userVarsService.set).not.toHaveBeenCalled();
+ });
+
+ it('should offer the step again after a failed attempt left nothing behind', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+ jest
+ .spyOn(dataSource, 'transaction')
+ .mockRejectedValueOnce(new Error('user vars down'));
+
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ });
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ });
+
+ expect(userVarsService.setIfNotExists).toHaveBeenCalledWith(
+ {
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_OFFERED,
+ value: true,
+ },
+ mockQueryRunner,
+ );
+ });
+
+ it('should not throw when the user vars are unavailable', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+ jest
+ .spyOn(userVarsService, 'setIfNotExists')
+ .mockRejectedValue(new Error('user vars down'));
+
+ await expect(
+ service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ }),
+ ).resolves.toBe(false);
+ });
+
+ it('should not flag the step when the transaction exposes no query runner', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+ transactionQueryRunner = undefined;
+
+ await expect(
+ service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 320,
+ }),
+ ).resolves.toBe(false);
+
+ expect(userVarsService.setIfNotExists).not.toHaveBeenCalled();
+ });
+
+ it('should clear the pending var when the step is completed', async () => {
+ await service.setOnboardingBookCallPending({
+ userId,
+ workspaceId,
+ value: false,
+ });
+
+ expect(userVarsService.delete).toHaveBeenCalledWith(
+ {
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
+ },
+ undefined,
+ );
+ expect(userVarsService.set).not.toHaveBeenCalled();
+ });
+
+ it.each([50, 51])(
+ 'should flag the step when the employee count is %s',
+ async (employeeCount) => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount,
+ });
+
+ expect(userVarsService.set).toHaveBeenCalledWith(
+ {
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
+ value: true,
+ },
+ mockQueryRunner,
+ );
+ },
+ );
+
+ it('should not flag the step below the threshold', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 49,
+ });
+
+ expect(userVarsService.set).not.toHaveBeenCalled();
+ });
+
+ it('should not flag the step without an employee count', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: null,
+ });
+
+ expect(userVarsService.set).not.toHaveBeenCalled();
+ });
+
+ it('should not flag the step when no threshold is configured', async () => {
+ mockConfig({ calendarBookingPageId: 'team/twenty/talk-to-us' });
+
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 5000,
+ });
+
+ expect(userVarsService.set).not.toHaveBeenCalled();
+ });
+
+ it('should treat a zero threshold as unconfigured', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 0,
+ });
+
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 5000,
+ });
+
+ expect(userVarsService.set).not.toHaveBeenCalled();
+ });
+
+ it('should not flag the step when no booking page is configured', async () => {
+ mockConfig({ minEmployeeCount: 50 });
+
+ await service.setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount: 5000,
+ });
+
+ expect(userVarsService.set).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('isOnboardingBookCallPending', () => {
+ const mockConfig = ({
+ calendarBookingPageId,
+ minEmployeeCount,
+ }: {
+ calendarBookingPageId?: string;
+ minEmployeeCount?: number;
+ }) => {
+ jest
+ .spyOn(twentyConfigService, 'get')
+ .mockImplementation((key: string) =>
+ key === 'CALENDAR_BOOKING_PAGE_ID'
+ ? calendarBookingPageId
+ : minEmployeeCount,
+ );
+ };
+
+ it('should report the stored pending var', async () => {
+ mockConfig({
+ calendarBookingPageId: 'team/twenty/talk-to-us',
+ minEmployeeCount: 50,
+ });
+ jest.spyOn(userVarsService, 'get').mockResolvedValue(true);
+
+ expect(
+ await service.isOnboardingBookCallPending({ userId, workspaceId }),
+ ).toBe(true);
+ expect(userVarsService.get).toHaveBeenCalledWith({
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
+ });
+ });
+
+ it('should report false without reading the var when the step is unconfigured', async () => {
+ mockConfig({ minEmployeeCount: 50 });
+
+ expect(
+ await service.isOnboardingBookCallPending({ userId, workspaceId }),
+ ).toBe(false);
+ expect(userVarsService.get).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
index 35d77bad87..1d20dbacae 100644
--- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
@@ -1,9 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
+import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
+import { isNumber } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
-import { type QueryRunner, Repository } from 'typeorm';
+import { type DataSource, type QueryRunner, Repository } from 'typeorm';
import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
@@ -16,6 +17,7 @@ import {
INSTALL_ONBOARDING_APPS_JOB_NAME,
type InstallOnboardingAppsJobData,
} from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
+import { readBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -27,6 +29,8 @@ export enum OnboardingStepKeys {
ONBOARDING_INVITE_TEAM_PENDING = 'ONBOARDING_INVITE_TEAM_PENDING',
ONBOARDING_CREATE_PROFILE_PENDING = 'ONBOARDING_CREATE_PROFILE_PENDING',
ONBOARDING_INSTALL_APPS_PENDING = 'ONBOARDING_INSTALL_APPS_PENDING',
+ ONBOARDING_BOOK_CALL_PENDING = 'ONBOARDING_BOOK_CALL_PENDING',
+ ONBOARDING_BOOK_CALL_OFFERED = 'ONBOARDING_BOOK_CALL_OFFERED',
}
export type OnboardingKeyValueTypeMap = {
@@ -34,6 +38,8 @@ export type OnboardingKeyValueTypeMap = {
[OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING]: boolean;
[OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_PENDING]: boolean;
[OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING]: boolean;
+ [OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING]: boolean;
+ [OnboardingStepKeys.ONBOARDING_BOOK_CALL_OFFERED]: boolean;
};
@Injectable()
@@ -51,6 +57,8 @@ export class OnboardingService {
private readonly userWorkspaceRepository: Repository,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
+ @InjectDataSource()
+ private readonly dataSource: DataSource,
) {}
private isWorkspaceActivationPending(workspace: WorkspaceEntity) {
@@ -103,6 +111,9 @@ export class OnboardingService {
const isInviteTeamPending =
userVars.get(OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING) === true;
+ const isBookCallPending =
+ userVars.get(OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING) === true;
+
if (isConnectAccountPending) {
return OnboardingStatus.SYNC_EMAIL;
}
@@ -119,11 +130,20 @@ export class OnboardingService {
return OnboardingStatus.INVITE_TEAM;
}
- if (
+ const isPlanRequired =
await this.billingService.isSubscriptionIncompleteOnboardingStatus(
workspace.id,
- )
+ );
+
+ if (
+ isBookCallPending &&
+ isPlanRequired &&
+ isDefined(readBookCallStepMinEmployeeCount(this.twentyConfigService))
) {
+ return OnboardingStatus.BOOK_CALL;
+ }
+
+ if (isPlanRequired) {
return OnboardingStatus.PLAN_REQUIRED;
}
@@ -394,6 +414,131 @@ export class OnboardingService {
);
}
+ async isOnboardingBookCallPending({
+ userId,
+ workspaceId,
+ }: {
+ userId: string;
+ workspaceId: string;
+ }): Promise {
+ if (
+ !isDefined(readBookCallStepMinEmployeeCount(this.twentyConfigService))
+ ) {
+ return false;
+ }
+
+ return (
+ (await this.userVarsService.get({
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
+ })) === true
+ );
+ }
+
+ async setOnboardingBookCallPending(
+ {
+ userId,
+ workspaceId,
+ value,
+ }: {
+ userId: string;
+ workspaceId: string;
+ value: boolean;
+ },
+ queryRunner?: QueryRunner,
+ ) {
+ if (!value) {
+ await this.userVarsService.delete(
+ {
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
+ },
+ queryRunner,
+ );
+
+ return;
+ }
+
+ await this.userVarsService.set(
+ {
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
+ value: true,
+ },
+ queryRunner,
+ );
+ }
+
+ async setOnboardingBookCallPendingIfQualified({
+ userId,
+ workspaceId,
+ employeeCount,
+ }: {
+ userId: string;
+ workspaceId: string;
+ employeeCount: number | null;
+ }): Promise {
+ const minEmployeeCount = readBookCallStepMinEmployeeCount(
+ this.twentyConfigService,
+ );
+
+ if (
+ !isDefined(minEmployeeCount) ||
+ !isNumber(employeeCount) ||
+ employeeCount < minEmployeeCount
+ ) {
+ return false;
+ }
+
+ try {
+ return await this.dataSource.transaction(async (entityManager) => {
+ const { queryRunner } = entityManager;
+
+ if (!isDefined(queryRunner)) {
+ throw new Error('Transaction entity manager has no query runner');
+ }
+
+ // Claiming the offer is the single-winner gate: a concurrent enrichment
+ // loses the insert and must not resurrect a step the user already skipped.
+ const hasClaimedBookCallOffer =
+ await this.userVarsService.setIfNotExists(
+ {
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_OFFERED,
+ value: true,
+ },
+ queryRunner,
+ );
+
+ if (!hasClaimedBookCallOffer) {
+ return false;
+ }
+
+ await this.setOnboardingBookCallPending(
+ {
+ userId,
+ workspaceId,
+ value: true,
+ },
+ queryRunner,
+ );
+
+ return true;
+ });
+ } catch (error) {
+ this.logger.error(
+ `Failed to flag the book-call onboarding step for user ${userId} in workspace ${workspaceId}`,
+ error,
+ );
+
+ return false;
+ }
+ }
+
async setOnboardingCreateProfilePending(
{
userId,
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/utils/__tests__/get-book-call-step-min-employee-count.util.spec.ts b/packages/twenty-server/src/engine/core-modules/onboarding/utils/__tests__/get-book-call-step-min-employee-count.util.spec.ts
new file mode 100644
index 0000000000..49319b55af
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/utils/__tests__/get-book-call-step-min-employee-count.util.spec.ts
@@ -0,0 +1,62 @@
+import { getBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/get-book-call-step-min-employee-count.util';
+
+describe('getBookCallStepMinEmployeeCount', () => {
+ const calendarBookingPageId = 'team/twenty/talk-to-us';
+
+ it('should return the threshold when both settings are configured', () => {
+ expect(
+ getBookCallStepMinEmployeeCount({
+ calendarBookingPageId,
+ minEmployeeCount: 50,
+ }),
+ ).toBe(50);
+ });
+
+ it('should treat a zero threshold as disabled', () => {
+ expect(
+ getBookCallStepMinEmployeeCount({
+ calendarBookingPageId,
+ minEmployeeCount: 0,
+ }),
+ ).toBeNull();
+ });
+
+ it('should treat a negative threshold as disabled', () => {
+ expect(
+ getBookCallStepMinEmployeeCount({
+ calendarBookingPageId,
+ minEmployeeCount: -1,
+ }),
+ ).toBeNull();
+ });
+
+ it('should accept the smallest enabling threshold', () => {
+ expect(
+ getBookCallStepMinEmployeeCount({
+ calendarBookingPageId,
+ minEmployeeCount: 1,
+ }),
+ ).toBe(1);
+ });
+
+ it('should be disabled without a threshold', () => {
+ expect(
+ getBookCallStepMinEmployeeCount({
+ calendarBookingPageId,
+ minEmployeeCount: undefined,
+ }),
+ ).toBeNull();
+ });
+
+ it.each([undefined, ''])(
+ 'should be disabled when the booking page id is %p',
+ (bookingPageId) => {
+ expect(
+ getBookCallStepMinEmployeeCount({
+ calendarBookingPageId: bookingPageId,
+ minEmployeeCount: 50,
+ }),
+ ).toBeNull();
+ },
+ );
+});
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/utils/get-book-call-step-min-employee-count.util.ts b/packages/twenty-server/src/engine/core-modules/onboarding/utils/get-book-call-step-min-employee-count.util.ts
new file mode 100644
index 0000000000..4d22959716
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/utils/get-book-call-step-min-employee-count.util.ts
@@ -0,0 +1,19 @@
+import { isNonEmptyString, isNumber } from '@sniptt/guards';
+
+export const getBookCallStepMinEmployeeCount = ({
+ calendarBookingPageId,
+ minEmployeeCount,
+}: {
+ calendarBookingPageId: string | undefined;
+ minEmployeeCount: number | undefined;
+}): number | null => {
+ if (
+ !isNonEmptyString(calendarBookingPageId) ||
+ !isNumber(minEmployeeCount) ||
+ minEmployeeCount <= 0
+ ) {
+ return null;
+ }
+
+ return minEmployeeCount;
+};
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util.ts b/packages/twenty-server/src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util.ts
new file mode 100644
index 0000000000..bb4758e83f
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util.ts
@@ -0,0 +1,12 @@
+import { getBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/get-book-call-step-min-employee-count.util';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+
+export const readBookCallStepMinEmployeeCount = (
+ twentyConfigService: TwentyConfigService,
+): number | null =>
+ getBookCallStepMinEmployeeCount({
+ calendarBookingPageId: twentyConfigService.get('CALENDAR_BOOKING_PAGE_ID'),
+ minEmployeeCount: twentyConfigService.get(
+ 'ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT',
+ ),
+ });
diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
index 67341198b6..a42c2f5790 100644
--- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
+++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
@@ -1088,6 +1088,18 @@ export class ConfigVariables {
@IsOptional()
CALENDAR_BOOKING_PAGE_ID?: string;
+ @ConfigVariablesMetadata({
+ group: ConfigVariablesGroup.ADVANCED_SETTINGS,
+ description:
+ 'Minimum enriched company employee count required to show the book-a-call onboarding step. Leave unset or set to 0 to disable the step. The step also requires CALENDAR_BOOKING_PAGE_ID.',
+ isHiddenInAdminPanel: true,
+ type: ConfigVariableType.NUMBER,
+ })
+ @CastToPositiveNumber()
+ @IsInt()
+ @IsOptional()
+ ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT?: number;
+
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LOGGING,
description: 'Enable or disable buffering for logs before sending',
diff --git a/packages/twenty-server/src/engine/core-modules/user/user-vars/services/user-vars.service.ts b/packages/twenty-server/src/engine/core-modules/user/user-vars/services/user-vars.service.ts
index bb301fda20..b6ddbfac60 100644
--- a/packages/twenty-server/src/engine/core-modules/user/user-vars/services/user-vars.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/user-vars/services/user-vars.service.ts
@@ -154,6 +154,32 @@ export class UserVarsService<
);
}
+ setIfNotExists(
+ {
+ userId,
+ workspaceId,
+ key,
+ value,
+ }: {
+ userId?: string;
+ workspaceId?: string;
+ key: Extract;
+ value: KeyValueTypesMap[K];
+ },
+ queryRunner?: QueryRunner,
+ ): Promise {
+ return this.keyValuePairService.setIfNotExists(
+ {
+ userId,
+ workspaceId,
+ key,
+ value,
+ type: KeyValuePairType.USER_VARIABLE,
+ },
+ queryRunner,
+ );
+ }
+
async delete(
{
userId,
diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
index b2dfe47709..1e17198287 100644
--- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
@@ -212,6 +212,7 @@ export class UserResolver {
const userVarAllowList: string[] = [
OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING,
+ OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
AccountsToReconnectKeys.ACCOUNTS_TO_RECONNECT_INSUFFICIENT_PERMISSIONS,
AccountsToReconnectKeys.ACCOUNTS_TO_RECONNECT_EMAIL_ALIASES,
];