Show connect emails step to invited users without granting credits (#23058)
Invited users never saw the connect-emails onboarding step, so they could not connect their inbox while onboarding. Now they do, but only the first user (the workspace creator) earns the import-contacts reward for it. The credit is gated on the workspace having a single member, reusing the same "first user" signal the frontend already uses to gate the invite-team step. The connect-account step is still claimed for everyone so invited users' onboarding advances normally. The frontend hides the "free credits" tag and the header counter bump for invited users, so we do not promise credits they will not receive. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23058?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+2
-2
@@ -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 () => {
|
||||
|
||||
@@ -331,7 +331,7 @@ export class SignInUpService {
|
||||
await this.activateOnboardingForUser({
|
||||
user,
|
||||
workspace: params.workspace,
|
||||
shouldShowConnectAccountStep: false,
|
||||
shouldShowConnectAccountStep: true,
|
||||
shouldShowInstallAppsStep: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
+52
-1
@@ -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<UserWorkspaceEntity>;
|
||||
|
||||
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<MessageQueueService>(
|
||||
getQueueToken(MessageQueue.workspaceQueue),
|
||||
);
|
||||
userWorkspaceRepository = module.get<Repository<UserWorkspaceEntity>>(
|
||||
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', () => {
|
||||
|
||||
@@ -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<WorkspaceEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@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<boolean> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user