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, ];