Only show the install-apps onboarding step to workspace creators (#22990)
New users joining an existing workspace through an invite link were routed through the "Install your first apps" onboarding step, which is meant for workspace creation only. #22347 set `ONBOARDING_INSTALL_APPS_PENDING` inside `activateOnboardingForUser`, which is shared by both sign-up paths. Gate it per path like the connect-account step: `true` on `signUpOnNewWorkspace`, `false` on `signInUpOnExistingWorkspace`. Verified locally: invited users now go straight from create-profile into the workspace, and workspace creators still get the install-apps step. Covered by a unit test on the invite path and integration tests asserting the onboarding status per sign-up path (invite → `PROFILE_CREATION`; workspace creation → `SYNC_EMAIL` then `APPS_INSTALLATION`).
This commit is contained in:
+50
-9
@@ -1,3 +1,5 @@
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
@@ -57,6 +59,20 @@ const createSignInUpServiceForTests = () => {
|
||||
release: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUserWorkspaceService = {
|
||||
create: jest.fn(),
|
||||
checkUserWorkspaceExists: jest.fn(),
|
||||
addUserToWorkspaceIfUserNotInWorkspace: jest.fn(),
|
||||
};
|
||||
|
||||
const mockOnboardingService = {
|
||||
setOnboardingConnectAccountPending: jest.fn(),
|
||||
setOnboardingCreateProfilePending: jest.fn(),
|
||||
setOnboardingInstallAppsPending: jest.fn(),
|
||||
setOnboardingInviteTeamPending: jest.fn(),
|
||||
createOnboardingStatusForWorkspaceMember: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new SignInUpService(
|
||||
mockUserRepository as any,
|
||||
mockWorkspaceRepository as any,
|
||||
@@ -64,15 +80,8 @@ const createSignInUpServiceForTests = () => {
|
||||
validatePersonalInvitation: jest.fn(),
|
||||
invalidateWorkspaceInvitation: jest.fn(),
|
||||
} as any,
|
||||
{
|
||||
create: jest.fn(),
|
||||
checkUserWorkspaceExists: jest.fn(),
|
||||
} as any,
|
||||
{
|
||||
setOnboardingCreateProfilePending: jest.fn(),
|
||||
setOnboardingInviteTeamPending: jest.fn(),
|
||||
createOnboardingStatusForWorkspaceMember: jest.fn(),
|
||||
} as any,
|
||||
mockUserWorkspaceService as any,
|
||||
mockOnboardingService as any,
|
||||
{
|
||||
emitCustomBatchEvent: jest.fn(),
|
||||
} as any,
|
||||
@@ -121,6 +130,7 @@ const createSignInUpServiceForTests = () => {
|
||||
mockUserRepository,
|
||||
mockWorkspaceRepository,
|
||||
mockConfigurationValues,
|
||||
mockOnboardingService,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -309,3 +319,34 @@ 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 () => {
|
||||
const { service, mockOnboardingService } = createSignInUpServiceForTests();
|
||||
|
||||
await service.signInUpOnExistingWorkspace({
|
||||
workspace: {
|
||||
id: 'existing-workspace-id',
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
} as any,
|
||||
userData: {
|
||||
type: 'newUserWithPicture',
|
||||
newUserWithPicture: {
|
||||
email: 'invited.user@acme.dev',
|
||||
firstName: 'Invited',
|
||||
lastName: 'User',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOnboardingService.setOnboardingCreateProfilePending,
|
||||
).toHaveBeenCalledWith(expect.objectContaining({ value: true }), undefined);
|
||||
expect(
|
||||
mockOnboardingService.setOnboardingInstallAppsPending,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOnboardingService.setOnboardingConnectAccountPending,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,6 +332,7 @@ export class SignInUpService {
|
||||
user,
|
||||
workspace: params.workspace,
|
||||
shouldShowConnectAccountStep: false,
|
||||
shouldShowInstallAppsStep: false,
|
||||
});
|
||||
|
||||
await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace(
|
||||
@@ -364,10 +365,12 @@ export class SignInUpService {
|
||||
user,
|
||||
workspace,
|
||||
shouldShowConnectAccountStep,
|
||||
shouldShowInstallAppsStep,
|
||||
}: {
|
||||
user: Pick<UserEntity, 'id' | 'firstName' | 'lastName'>;
|
||||
workspace: WorkspaceEntity;
|
||||
shouldShowConnectAccountStep: boolean;
|
||||
shouldShowInstallAppsStep: boolean;
|
||||
},
|
||||
queryRunner?: QueryRunner,
|
||||
) {
|
||||
@@ -391,14 +394,16 @@ export class SignInUpService {
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await this.onboardingService.setOnboardingInstallAppsPending(
|
||||
{
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
value: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
if (shouldShowInstallAppsStep) {
|
||||
await this.onboardingService.setOnboardingInstallAppsPending(
|
||||
{
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
value: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveNewUser(
|
||||
@@ -670,6 +675,7 @@ export class SignInUpService {
|
||||
user,
|
||||
workspace,
|
||||
shouldShowConnectAccountStep: true,
|
||||
shouldShowInstallAppsStep: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
|
||||
import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util';
|
||||
import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
|
||||
|
||||
describe('Onboarding status when signing up in an existing workspace (integration)', () => {
|
||||
let createdUserAccessToken: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (isDefined(createdUserAccessToken)) {
|
||||
await deleteUser({
|
||||
accessToken: createdUserAccessToken,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
createdUserAccessToken = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('should only require profile creation for a new user joining through an invite', async () => {
|
||||
createdUserAccessToken = await signUpInWorkspaceAndGetAccessToken(
|
||||
`invited-onboarding-status-${randomUUID()}@example.com`,
|
||||
);
|
||||
|
||||
const {
|
||||
data: { currentUser },
|
||||
} = await getOnboardingStatus({
|
||||
accessToken: createdUserAccessToken,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(currentUser.onboardingStatus).toBe(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
);
|
||||
});
|
||||
});
|
||||
+16
-53
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
|
||||
|
||||
import gql from 'graphql-tag';
|
||||
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 { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
@@ -76,49 +77,17 @@ describe('updateWorkspaceMemberSettings and profile onboarding', () => {
|
||||
|
||||
newUserAccessToken = await signUpInWorkspaceAndGetAccessToken(uniqueEmail);
|
||||
|
||||
const triggerInstallAppsOnboardingStepMutation = gql`
|
||||
mutation TriggerInstallAppsOnboardingStep(
|
||||
$universalIdentifiers: [String!]!
|
||||
) {
|
||||
triggerInstallAppsOnboardingStep(
|
||||
universalIdentifiers: $universalIdentifiers
|
||||
) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
const {
|
||||
data: { currentUser: currentUserBeforeNameUpdate },
|
||||
} = await getOnboardingStatus({
|
||||
accessToken: newUserAccessToken,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
await makeMetadataAPIRequest(
|
||||
{
|
||||
query: triggerInstallAppsOnboardingStepMutation,
|
||||
variables: { universalIdentifiers: [] },
|
||||
},
|
||||
newUserAccessToken,
|
||||
expect(currentUserBeforeNameUpdate.onboardingStatus).toBe(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
);
|
||||
|
||||
const currentUserWithOnboardingQuery = gql`
|
||||
query CurrentUserWithOnboarding {
|
||||
currentUser {
|
||||
id
|
||||
onboardingStatus
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const beforeNameUpdateResponse = await makeMetadataAPIRequest(
|
||||
{
|
||||
query: currentUserWithOnboardingQuery,
|
||||
variables: {},
|
||||
},
|
||||
newUserAccessToken,
|
||||
);
|
||||
|
||||
expect(beforeNameUpdateResponse.status).toBe(200);
|
||||
expect(beforeNameUpdateResponse.body.errors).toBeUndefined();
|
||||
expect(
|
||||
beforeNameUpdateResponse.body.data.currentUser.onboardingStatus,
|
||||
).toBe(OnboardingStatus.PROFILE_CREATION);
|
||||
|
||||
const workspaceMemberQuery = gql`
|
||||
query WorkspaceMemberForProfileOnboarding(
|
||||
$workspaceMemberFilter: WorkspaceMemberFilterInput!
|
||||
@@ -183,20 +152,14 @@ describe('updateWorkspaceMemberSettings and profile onboarding', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
const afterNameUpdateResponse = await makeMetadataAPIRequest(
|
||||
{
|
||||
query: currentUserWithOnboardingQuery,
|
||||
variables: {},
|
||||
},
|
||||
newUserAccessToken,
|
||||
);
|
||||
const {
|
||||
data: { currentUser: currentUserAfterNameUpdate },
|
||||
} = await getOnboardingStatus({
|
||||
accessToken: newUserAccessToken,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(afterNameUpdateResponse.status).toBe(200);
|
||||
expect(afterNameUpdateResponse.body.errors).toBeUndefined();
|
||||
expect(
|
||||
afterNameUpdateResponse.body.data.currentUser.onboardingStatus,
|
||||
).not.toBe(OnboardingStatus.PROFILE_CREATION);
|
||||
expect(afterNameUpdateResponse.body.data.currentUser.onboardingStatus).toBe(
|
||||
expect(currentUserAfterNameUpdate.onboardingStatus).toBe(
|
||||
OnboardingStatus.COMPLETED,
|
||||
);
|
||||
});
|
||||
|
||||
+30
@@ -5,7 +5,9 @@ import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
|
||||
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
|
||||
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
|
||||
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
|
||||
import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util';
|
||||
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
|
||||
import { skipSyncEmailOnboardingStep } from 'test/integration/graphql/utils/skip-sync-email-onboarding-step.util';
|
||||
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
|
||||
import { createOneLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/create-logic-function.util';
|
||||
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
||||
@@ -13,6 +15,7 @@ import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
|
||||
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
|
||||
|
||||
describe('Successful user and workspace creation', () => {
|
||||
@@ -79,6 +82,33 @@ describe('Successful user and workspace creation', () => {
|
||||
WorkspaceActivationStatus.ACTIVE,
|
||||
);
|
||||
|
||||
const {
|
||||
data: { currentUser: currentUserAfterActivation },
|
||||
} = await getOnboardingStatus({
|
||||
accessToken: newWorkspaceAccessToken,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(currentUserAfterActivation.onboardingStatus).toBe(
|
||||
OnboardingStatus.SYNC_EMAIL,
|
||||
);
|
||||
|
||||
await skipSyncEmailOnboardingStep({
|
||||
accessToken: newWorkspaceAccessToken,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const {
|
||||
data: { currentUser: currentUserAfterSyncEmailSkip },
|
||||
} = await getOnboardingStatus({
|
||||
accessToken: newWorkspaceAccessToken,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(currentUserAfterSyncEmailSkip.onboardingStatus).toBe(
|
||||
OnboardingStatus.APPS_INSTALLATION,
|
||||
);
|
||||
|
||||
const {
|
||||
data: { currentUser },
|
||||
} = await getCurrentUser({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
import { type OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
|
||||
|
||||
type GetOnboardingStatusUtilArgs = {
|
||||
accessToken: string;
|
||||
expectToFail?: boolean;
|
||||
};
|
||||
|
||||
export const getOnboardingStatus = async ({
|
||||
accessToken,
|
||||
expectToFail,
|
||||
}: GetOnboardingStatusUtilArgs): CommonResponseBody<{
|
||||
currentUser: { onboardingStatus: OnboardingStatus | null };
|
||||
}> => {
|
||||
const query = gql`
|
||||
query CurrentUserOnboardingStatus {
|
||||
currentUser {
|
||||
onboardingStatus
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await makeMetadataAPIRequest(
|
||||
{
|
||||
query,
|
||||
variables: {},
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Get onboarding status should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Get onboarding status has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
type SkipSyncEmailOnboardingStepUtilArgs = {
|
||||
accessToken: string;
|
||||
expectToFail?: boolean;
|
||||
};
|
||||
|
||||
export const skipSyncEmailOnboardingStep = async ({
|
||||
accessToken,
|
||||
expectToFail,
|
||||
}: SkipSyncEmailOnboardingStepUtilArgs): CommonResponseBody<{
|
||||
skipSyncEmailOnboardingStep: { success: boolean };
|
||||
}> => {
|
||||
const mutation = gql`
|
||||
mutation SkipSyncEmailOnboardingStep {
|
||||
skipSyncEmailOnboardingStep {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await makeMetadataAPIRequest(
|
||||
{
|
||||
query: mutation,
|
||||
variables: {},
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage:
|
||||
'Skip sync email onboarding step should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Skip sync email onboarding step has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
Reference in New Issue
Block a user