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:
Raphaël Bosi
2026-07-20 17:00:39 +02:00
committed by GitHub
parent 45b319ef2d
commit 240e185323
9 changed files with 128 additions and 15 deletions
@@ -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],
@@ -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(