diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx index 8423062362..591cae3d08 100644 --- a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx +++ b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx @@ -1,3 +1,4 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState'; import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState'; @@ -47,11 +48,17 @@ export const SyncEmails = () => { clientConfigApiStatusState, ).isLoadedOnce; const onboardingConfig = useAtomStateValue(onboardingConfigState); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + + const isFirstWorkspaceUser = currentWorkspace?.workspaceMembersCount === 1; + const creditsReward = isFirstWorkspaceUser + ? onboardingConfig?.importContactsCreditsReward + : undefined; const connectWithProvider = async (provider: ConnectedAccountProvider) => { setOnboardingFreeCredits((current) => ({ ...current, - importContacts: onboardingConfig?.importContactsCreditsReward ?? 0, + importContacts: creditsReward ?? 0, })); try { @@ -94,7 +101,7 @@ export const SyncEmails = () => { return ( connectWithProvider(ConnectedAccountProvider.GOOGLE) diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx index 72c8336b95..f236ece16b 100644 --- a/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx +++ b/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx @@ -1,9 +1,11 @@ import { getOperationName } from '~/utils/getOperationName'; import { type Meta, type StoryObj } from '@storybook/react-vite'; import { HttpResponse, graphql } from 'msw'; -import { within } from 'storybook/test'; +import { expect, within } from 'storybook/test'; import { AppPath } from 'twenty-shared/types'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; import { OnboardingStatus } from '~/generated-metadata/graphql'; import { GET_CURRENT_USER } from '~/modules/users/graphql/queries/getCurrentUser'; import { SyncEmails } from '~/pages/onboarding/SyncEmails'; @@ -12,7 +14,17 @@ import { type PageDecoratorArgs, } from '~/testing/decorators/PageDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; -import { mockedOnboardingUserData } from '~/testing/mock-data/users'; +import { + mockCurrentWorkspace, + mockedOnboardingUserData, +} from '~/testing/mock-data/users'; + +const setWorkspaceMembersCount = (workspaceMembersCount: number) => { + jotaiStore.set(currentWorkspaceState.atom, { + ...mockCurrentWorkspace, + workspaceMembersCount, + }); +}; const meta: Meta = { title: 'Pages/Onboarding/SyncEmails', @@ -42,8 +54,23 @@ export default meta; export type Story = StoryObj; export const Default: Story = { + beforeEach: () => { + setWorkspaceMembersCount(1); + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement.ownerDocument.body); await canvas.findByText('Import your contacts'); + await canvas.findByText('Earn +2'); + }, +}; + +export const InvitedUser: Story = { + beforeEach: () => { + setWorkspaceMembersCount(2); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + await canvas.findByText('Import your contacts'); + expect(canvas.queryByText('Earn +2')).not.toBeInTheDocument(); }, }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 7dc7b9051b..cc87a76543 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -327,7 +327,7 @@ describe('SignInUpService workspace-creation policy', () => { }); describe('SignInUpService onboarding steps', () => { - it('does not flag the install-apps step for a new user joining an existing workspace', async () => { + it('flags the connect-account step but not the install-apps step for a new user joining an existing workspace', async () => { const { service, mockOnboardingService } = createSignInUpServiceForTests(); await service.signInUpOnExistingWorkspace({ @@ -353,7 +353,7 @@ describe('SignInUpService onboarding steps', () => { ).not.toHaveBeenCalled(); expect( mockOnboardingService.setOnboardingConnectAccountPending, - ).not.toHaveBeenCalled(); + ).toHaveBeenCalledWith(expect.objectContaining({ value: true }), undefined); }); it('flags the install-apps step for a user creating a new workspace', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index cba90cfcb4..d312c2f8e5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -331,7 +331,7 @@ export class SignInUpService { await this.activateOnboardingForUser({ user, workspace: params.workspace, - shouldShowConnectAccountStep: false, + shouldShowConnectAccountStep: true, shouldShowInstallAppsStep: false, }); diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts index 3c90ecba66..56a94e9ced 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts @@ -5,6 +5,7 @@ import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { OnboardingResolver } from 'src/engine/core-modules/onboarding/onboarding.resolver'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { OnboardingInviteSuggestionsModule } from 'src/modules/onboarding-invite-suggestions/onboarding-invite-suggestions.module'; @@ -13,7 +14,7 @@ import { OnboardingInviteSuggestionsModule } from 'src/modules/onboarding-invite BillingModule, UserVarsModule, OnboardingInviteSuggestionsModule, - TypeOrmModule.forFeature([WorkspaceEntity]), + TypeOrmModule.forFeature([WorkspaceEntity, UserWorkspaceEntity]), ], exports: [OnboardingService], providers: [OnboardingService, OnboardingResolver], 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 7a1178d52a..128d5542ab 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 @@ -16,6 +16,7 @@ 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 { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; describe('OnboardingService', () => { @@ -24,6 +25,7 @@ describe('OnboardingService', () => { let billingCreditService: BillingCreditService; let twentyConfigService: TwentyConfigService; let messageQueueService: MessageQueueService; + let userWorkspaceRepository: Repository; const userId = 'user-id'; const workspaceId = 'workspace-id'; @@ -62,6 +64,12 @@ describe('OnboardingService', () => { provide: getRepositoryToken(WorkspaceEntity), useClass: Repository, }, + { + provide: getRepositoryToken(UserWorkspaceEntity), + useValue: { + countBy: jest.fn(), + }, + }, { provide: getQueueToken(MessageQueue.workspaceQueue), useValue: { @@ -79,6 +87,9 @@ describe('OnboardingService', () => { messageQueueService = module.get( getQueueToken(MessageQueue.workspaceQueue), ); + userWorkspaceRepository = module.get>( + getRepositoryToken(UserWorkspaceEntity), + ); }); afterEach(() => { @@ -86,8 +97,9 @@ describe('OnboardingService', () => { }); describe('completeOnboardingConnectAccountStep', () => { - it('should credit the import-contacts reward when the step was claimed', async () => { + it('should credit the import-contacts reward when the step was claimed by the first workspace user', async () => { jest.spyOn(userVarsService, 'delete').mockResolvedValue(1); + jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(1); jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000); await service.completeOnboardingConnectAccountStep({ @@ -106,6 +118,25 @@ describe('OnboardingService', () => { }); }); + it('should claim the step but not credit when the workspace has more than one member', async () => { + jest.spyOn(userVarsService, 'delete').mockResolvedValue(1); + jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(2); + + await service.completeOnboardingConnectAccountStep({ + userId, + workspaceId, + }); + + expect(userVarsService.delete).toHaveBeenCalledWith({ + userId, + workspaceId, + key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING, + }); + expect( + billingCreditService.creditWorkspaceBalance, + ).not.toHaveBeenCalled(); + }); + it('should not credit anything when the step was already consumed', async () => { jest.spyOn(userVarsService, 'delete').mockResolvedValue(0); @@ -124,6 +155,7 @@ describe('OnboardingService', () => { .spyOn(userVarsService, 'delete') .mockResolvedValueOnce(1) .mockResolvedValueOnce(0); + jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(1); jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000); await Promise.all([ @@ -138,6 +170,7 @@ describe('OnboardingService', () => { it('should not throw when crediting fails', async () => { jest.spyOn(userVarsService, 'delete').mockResolvedValue(1); + jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(1); jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000); jest .spyOn(billingCreditService, 'creditWorkspaceBalance') @@ -150,6 +183,24 @@ describe('OnboardingService', () => { }), ).resolves.not.toThrow(); }); + + it('should not throw when the workspace member count is unavailable', async () => { + jest.spyOn(userVarsService, 'delete').mockResolvedValue(1); + jest + .spyOn(userWorkspaceRepository, 'countBy') + .mockRejectedValue(new Error('database failure')); + + await expect( + service.completeOnboardingConnectAccountStep({ + userId, + workspaceId, + }), + ).resolves.not.toThrow(); + + expect( + billingCreditService.creditWorkspaceBalance, + ).not.toHaveBeenCalled(); + }); }); describe('triggerInstallAppsOnboardingStep', () => { 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 81d984025a..35d77bad87 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 @@ -19,6 +19,7 @@ import { 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'; export enum OnboardingStepKeys { @@ -46,6 +47,8 @@ export class OnboardingService { private readonly twentyConfigService: TwentyConfigService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, + @InjectRepository(UserWorkspaceEntity) + private readonly userWorkspaceRepository: Repository, @InjectMessageQueue(MessageQueue.workspaceQueue) private readonly messageQueueService: MessageQueueService, ) {} @@ -190,7 +193,19 @@ export class OnboardingService { return; } - await this.creditImportContactsReward({ workspaceId }); + await this.creditImportContactsRewardForFirstWorkspaceUser({ workspaceId }); + } + + private async isFirstWorkspaceUser({ + workspaceId, + }: { + workspaceId: string; + }): Promise { + const workspaceUserCount = await this.userWorkspaceRepository.countBy({ + workspaceId, + }); + + return workspaceUserCount === 1; } private async claimOnboardingConnectAccountStep({ @@ -209,12 +224,20 @@ export class OnboardingService { return isDefined(affectedRows) && affectedRows > 0; } - private async creditImportContactsReward({ + private async creditImportContactsRewardForFirstWorkspaceUser({ workspaceId, }: { workspaceId: string; }) { try { + const isFirstWorkspaceUser = await this.isFirstWorkspaceUser({ + workspaceId, + }); + + if (!isFirstWorkspaceUser) { + return; + } + await this.billingCreditService.creditWorkspaceBalance({ workspaceId, amountMicro: this.twentyConfigService.get( diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/sign-up-in-workspace-onboarding-status.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/sign-up-in-workspace-onboarding-status.integration-spec.ts index 9aff364277..9c047c35a0 100644 --- a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/sign-up-in-workspace-onboarding-status.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/sign-up-in-workspace-onboarding-status.integration-spec.ts @@ -21,7 +21,7 @@ describe('Onboarding status when signing up in an existing workspace (integratio } }); - it('should only require profile creation for a new user joining through an invite', async () => { + it('should start with the connect-account step for a new user joining through an invite', async () => { createdUserAccessToken = await signUpInWorkspaceAndGetAccessToken( `invited-onboarding-status-${randomUUID()}@example.com`, ); @@ -33,8 +33,6 @@ describe('Onboarding status when signing up in an existing workspace (integratio expectToFail: false, }); - expect(currentUser.onboardingStatus).toBe( - OnboardingStatus.PROFILE_CREATION, - ); + expect(currentUser.onboardingStatus).toBe(OnboardingStatus.SYNC_EMAIL); }); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts index d4a0101af7..9d095d552e 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts @@ -5,6 +5,7 @@ import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util'; +import { skipSyncEmailOnboardingStep } from 'test/integration/graphql/utils/skip-sync-email-onboarding-step.util'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; @@ -77,6 +78,11 @@ describe('updateWorkspaceMemberSettings and profile onboarding', () => { newUserAccessToken = await signUpInWorkspaceAndGetAccessToken(uniqueEmail); + await skipSyncEmailOnboardingStep({ + accessToken: newUserAccessToken, + expectToFail: false, + }); + const { data: { currentUser: currentUserBeforeNameUpdate }, } = await getOnboardingStatus({