Add install your first apps onboarding V2 step (#22347)

https://github.com/user-attachments/assets/5326d48f-1842-4db1-bc7c-94852145c035


<img width="838" height="754" alt="CleanShot 2026-06-30 at 16 25 05@2x"
src="https://github.com/user-attachments/assets/5c7d53d7-4d65-4e35-aed1-edf0c104e140"
/>


Adds an "Install your first apps" step to the V2 onboarding, shown right
after import-contacts. It lets users opt into installing marketplace
apps (Call recorder and People Data Labs for now) during onboarding.

- New backend `OnboardingStatus.APPS_INSTALLATION` (between SYNC_EMAIL
and PROFILE_CREATION); V1 auto-skips it.
- The primary button sends the selected app ids to the server via
`triggerInstallAppsOnboardingStep`, which enqueues a dedicated job that
installs them asynchronously so onboarding isn't blocked. Skip continues
without installing.
- The workspace is credited per app on successful installation. Credits
are env-driven via `ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP`,
shown as "Earn +N free credits (1 per tool)".

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22347?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-01 14:25:16 +02:00
committed by GitHub
parent 93b8f66794
commit 2e6077383b
45 changed files with 1336 additions and 16 deletions
@@ -383,6 +383,15 @@ export class SignInUpService {
},
queryRunner,
);
await this.onboardingService.setOnboardingInstallAppsPending(
{
userId: user.id,
workspaceId: workspace.id,
value: true,
},
queryRunner,
);
}
private async saveNewUser(
@@ -89,6 +89,7 @@ describe('ClientConfigController', () => {
importContactsCreditsReward: 2,
inviteTeamCreditsRewardPerUser: 3,
upgradeCreditsReward: 5,
installAppsCreditsRewardPerApp: 1,
},
isAttachmentPreviewEnabled: true,
analyticsEnabled: false,
@@ -214,6 +214,8 @@ export class OnboardingConfig {
inviteTeamCreditsRewardPerUser: number;
upgradeCreditsReward: number;
installAppsCreditsRewardPerApp: number;
}
@ObjectType()
@@ -95,6 +95,7 @@ describe('ClientConfigService', () => {
ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD: 2_000_000,
ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER: 3_000_000,
BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD: 5_000_000,
ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP: 1_000_000,
IS_ATTACHMENT_PREVIEW_ENABLED: true,
ANALYTICS_ENABLED: true,
MESSAGING_PROVIDER_MICROSOFT_ENABLED: false,
@@ -172,6 +173,7 @@ describe('ClientConfigService', () => {
importContactsCreditsReward: 2,
inviteTeamCreditsRewardPerUser: 3,
upgradeCreditsReward: 5,
installAppsCreditsRewardPerApp: 1,
},
isAttachmentPreviewEnabled: true,
analyticsEnabled: true,
@@ -235,6 +235,11 @@ export class ClientConfigService {
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD',
),
),
installAppsCreditsRewardPerApp: toDisplayCredits(
this.twentyConfigService.get(
'ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP',
),
),
},
isAttachmentPreviewEnabled: this.twentyConfigService.get(
'IS_ATTACHMENT_PREVIEW_ENABLED',
@@ -12,8 +12,12 @@ import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/j
import { BillingReminderModule } from 'src/engine/core-modules/billing/reminders/billing-reminder.module';
import { BillingReminderCronJob } from 'src/engine/core-modules/billing/reminders/crons/billing-reminder.cron.job';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { BackfillApplicationInstallationJob } from 'src/engine/core-modules/application/jobs/backfill-application-installation.job';
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
import { InstallOnboardingAppsJob } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
@@ -88,6 +92,9 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
EnterpriseModule,
EmailingModule,
PreInstalledAppsModule,
ApplicationInstallModule,
ApplicationRegistrationModule,
OnboardingModule,
BillingReminderModule,
],
providers: [
@@ -103,6 +110,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
UpdateWorkspaceMemberEmailJob,
GenerateSdkClientJob,
BackfillApplicationInstallationJob,
InstallOnboardingAppsJob,
],
})
export class JobsModule {
@@ -0,0 +1,4 @@
export const ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS = [
'8da4b8b5-5edf-4880-b51f-ab6e679ec617',
'4a1178c1-3535-4a47-b592-231d3216b36f',
];
@@ -3,6 +3,7 @@ export enum OnboardingStatus {
WORKSPACE_ACTIVATION = 'WORKSPACE_ACTIVATION',
PROFILE_CREATION = 'PROFILE_CREATION',
SYNC_EMAIL = 'SYNC_EMAIL',
APPS_INSTALLATION = 'APPS_INSTALLATION',
INVITE_TEAM = 'INVITE_TEAM',
BOOK_ONBOARDING = 'BOOK_ONBOARDING',
COMPLETED = 'COMPLETED',
@@ -0,0 +1,6 @@
export const INSTALL_ONBOARDING_APPS_JOB_NAME = 'InstallOnboardingAppsJob';
export type InstallOnboardingAppsJobData = {
workspaceId: string;
universalIdentifiers: string[];
};
@@ -0,0 +1,158 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { type ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { InstallOnboardingAppsJob } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
describe('InstallOnboardingAppsJob', () => {
let job: InstallOnboardingAppsJob;
let applicationRegistrationService: ApplicationRegistrationService;
let applicationInstallService: ApplicationInstallService;
let onboardingService: OnboardingService;
const workspaceId = 'workspace-id';
const callRecorderId = 'call-recorder-uid';
const peopleDataLabsId = 'people-data-labs-uid';
const buildRegistration = (id: string) =>
({ id }) as ApplicationRegistrationEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
InstallOnboardingAppsJob,
{
provide: ApplicationRegistrationService,
useValue: {
findOneByUniversalIdentifier: jest.fn(),
},
},
{
provide: ApplicationInstallService,
useValue: {
installApplication: jest.fn(),
},
},
{
provide: OnboardingService,
useValue: {
creditInstallAppsReward: jest.fn(),
},
},
],
}).compile();
job = module.get<InstallOnboardingAppsJob>(InstallOnboardingAppsJob);
applicationRegistrationService = module.get<ApplicationRegistrationService>(
ApplicationRegistrationService,
);
applicationInstallService = module.get<ApplicationInstallService>(
ApplicationInstallService,
);
onboardingService = module.get<OnboardingService>(OnboardingService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should credit the reward for every requested app and install them', async () => {
jest
.spyOn(applicationRegistrationService, 'findOneByUniversalIdentifier')
.mockImplementation(async (universalIdentifier) =>
buildRegistration(`registration-${universalIdentifier}`),
);
jest
.spyOn(applicationInstallService, 'installApplication')
.mockResolvedValue(true);
await job.handle({
workspaceId,
universalIdentifiers: [callRecorderId, peopleDataLabsId],
});
expect(onboardingService.creditInstallAppsReward).toHaveBeenCalledWith({
workspaceId,
rewardAppsCount: 2,
});
expect(applicationInstallService.installApplication).toHaveBeenCalledTimes(
2,
);
});
it('should credit before attempting the installs', async () => {
jest
.spyOn(applicationRegistrationService, 'findOneByUniversalIdentifier')
.mockImplementation(async (universalIdentifier) =>
buildRegistration(`registration-${universalIdentifier}`),
);
jest
.spyOn(applicationInstallService, 'installApplication')
.mockResolvedValue(true);
await job.handle({
workspaceId,
universalIdentifiers: [callRecorderId],
});
const creditOrder = (onboardingService.creditInstallAppsReward as jest.Mock)
.mock.invocationCallOrder[0];
const installOrder = (
applicationInstallService.installApplication as jest.Mock
).mock.invocationCallOrder[0];
expect(creditOrder).toBeLessThan(installOrder);
});
it('should credit even when an install fails', async () => {
jest
.spyOn(applicationRegistrationService, 'findOneByUniversalIdentifier')
.mockImplementation(async (universalIdentifier) =>
buildRegistration(`registration-${universalIdentifier}`),
);
jest
.spyOn(applicationInstallService, 'installApplication')
.mockRejectedValue(new Error('install failure'));
await job.handle({
workspaceId,
universalIdentifiers: [callRecorderId, peopleDataLabsId],
});
expect(onboardingService.creditInstallAppsReward).toHaveBeenCalledWith({
workspaceId,
rewardAppsCount: 2,
});
expect(applicationInstallService.installApplication).toHaveBeenCalledTimes(
2,
);
});
it('should skip installing apps whose registration cannot be found but still credit the full count', async () => {
jest
.spyOn(applicationRegistrationService, 'findOneByUniversalIdentifier')
.mockImplementation(async (universalIdentifier) =>
universalIdentifier === callRecorderId
? null
: buildRegistration(`registration-${universalIdentifier}`),
);
jest
.spyOn(applicationInstallService, 'installApplication')
.mockResolvedValue(true);
await job.handle({
workspaceId,
universalIdentifiers: [callRecorderId, peopleDataLabsId],
});
expect(onboardingService.creditInstallAppsReward).toHaveBeenCalledWith({
workspaceId,
rewardAppsCount: 2,
});
expect(applicationInstallService.installApplication).toHaveBeenCalledTimes(
1,
);
});
});
@@ -0,0 +1,73 @@
import { Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import {
INSTALL_ONBOARDING_APPS_JOB_NAME,
type InstallOnboardingAppsJobData,
} from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@Processor(MessageQueue.workspaceQueue)
export class InstallOnboardingAppsJob {
private readonly logger = new Logger(InstallOnboardingAppsJob.name);
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationInstallService: ApplicationInstallService,
private readonly onboardingService: OnboardingService,
) {}
@Process(INSTALL_ONBOARDING_APPS_JOB_NAME)
async handle({
workspaceId,
universalIdentifiers,
}: InstallOnboardingAppsJobData): Promise<void> {
await this.onboardingService.creditInstallAppsReward({
workspaceId,
rewardAppsCount: universalIdentifiers.length,
});
for (const universalIdentifier of universalIdentifiers) {
await this.installApp({ universalIdentifier, workspaceId });
}
}
private async installApp({
universalIdentifier,
workspaceId,
}: {
universalIdentifier: string;
workspaceId: string;
}): Promise<void> {
try {
const registration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
if (!isDefined(registration)) {
this.logger.error(
`Onboarding app ${universalIdentifier} not found while installing for workspace ${workspaceId}`,
);
return;
}
await this.applicationInstallService.installApplication({
appRegistrationId: registration.id,
workspaceId,
});
} catch (error) {
this.logger.error(
`Failed to install onboarding app ${universalIdentifier} for workspace ${workspaceId}`,
error,
);
}
}
}
@@ -1,5 +1,5 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Mutation, Query } from '@nestjs/graphql';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -68,4 +68,21 @@ export class OnboardingResolver {
return { success: true };
}
@Mutation(() => OnboardingStepSuccessDTO)
@UseGuards(NoPermissionGuard)
async triggerInstallAppsOnboardingStep(
@AuthUser() user: AuthContextUser,
@AuthWorkspace() workspace: WorkspaceEntity,
@Args({ name: 'universalIdentifiers', type: () => [String] })
universalIdentifiers: string[],
): Promise<OnboardingStepSuccessDTO> {
await this.onboardingService.triggerInstallAppsOnboardingStep({
userId: user.id,
workspaceId: workspace.id,
universalIdentifiers,
});
return { success: true };
}
}
@@ -5,6 +5,11 @@ import { 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';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
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 { INSTALL_ONBOARDING_APPS_JOB_NAME } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
import {
OnboardingService,
OnboardingStepKeys,
@@ -18,6 +23,7 @@ describe('OnboardingService', () => {
let userVarsService: UserVarsService;
let billingCreditService: BillingCreditService;
let twentyConfigService: TwentyConfigService;
let messageQueueService: MessageQueueService;
const userId = 'user-id';
const workspaceId = 'workspace-id';
@@ -56,6 +62,12 @@ describe('OnboardingService', () => {
provide: getRepositoryToken(WorkspaceEntity),
useClass: Repository,
},
{
provide: getQueueToken(MessageQueue.workspaceQueue),
useValue: {
add: jest.fn(),
},
},
],
}).compile();
@@ -64,6 +76,9 @@ describe('OnboardingService', () => {
billingCreditService =
module.get<BillingCreditService>(BillingCreditService);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
messageQueueService = module.get<MessageQueueService>(
getQueueToken(MessageQueue.workspaceQueue),
);
});
afterEach(() => {
@@ -136,4 +151,91 @@ describe('OnboardingService', () => {
).resolves.not.toThrow();
});
});
describe('triggerInstallAppsOnboardingStep', () => {
const [callRecorderId, peopleDataLabsId] =
ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS;
it('should claim the step and enqueue the install job for the installable apps without crediting', async () => {
jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
await service.triggerInstallAppsOnboardingStep({
userId,
workspaceId,
universalIdentifiers: [callRecorderId, peopleDataLabsId],
});
expect(userVarsService.delete).toHaveBeenCalledWith({
userId,
workspaceId,
key: OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING,
});
expect(messageQueueService.add).toHaveBeenCalledWith(
INSTALL_ONBOARDING_APPS_JOB_NAME,
{
workspaceId,
universalIdentifiers: [callRecorderId, peopleDataLabsId],
},
{ id: `${INSTALL_ONBOARDING_APPS_JOB_NAME}-${workspaceId}` },
);
expect(
billingCreditService.creditWorkspaceBalance,
).not.toHaveBeenCalled();
});
it('should not enqueue anything when the step was already consumed', async () => {
jest.spyOn(userVarsService, 'delete').mockResolvedValue(0);
await service.triggerInstallAppsOnboardingStep({
userId,
workspaceId,
universalIdentifiers: [callRecorderId],
});
expect(messageQueueService.add).not.toHaveBeenCalled();
});
it('should claim the step but not enqueue when no installable app was selected', async () => {
jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
await service.triggerInstallAppsOnboardingStep({
userId,
workspaceId,
universalIdentifiers: ['00000000-0000-0000-0000-000000000000'],
});
expect(userVarsService.delete).toHaveBeenCalled();
expect(messageQueueService.add).not.toHaveBeenCalled();
});
});
describe('creditInstallAppsReward', () => {
it('should credit the reward per installed app', async () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue(1_000_000);
await service.creditInstallAppsReward({
workspaceId,
rewardAppsCount: 2,
});
expect(billingCreditService.creditWorkspaceBalance).toHaveBeenCalledWith({
workspaceId,
amountMicro: 2_000_000,
});
});
it('should not throw when crediting fails', async () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue(1_000_000);
jest
.spyOn(billingCreditService, 'creditWorkspaceBalance')
.mockRejectedValue(new Error('billing failure'));
await expect(
service.creditInstallAppsReward({
workspaceId,
rewardAppsCount: 1,
}),
).resolves.not.toThrow();
});
});
});
@@ -8,7 +8,15 @@ import { 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';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
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,
type InstallOnboardingAppsJobData,
} from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
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';
@@ -19,6 +27,7 @@ export enum OnboardingStepKeys {
ONBOARDING_INVITE_TEAM_PENDING = 'ONBOARDING_INVITE_TEAM_PENDING',
ONBOARDING_CREATE_PROFILE_PENDING = 'ONBOARDING_CREATE_PROFILE_PENDING',
ONBOARDING_BOOK_ONBOARDING_PENDING = 'ONBOARDING_BOOK_ONBOARDING_PENDING',
ONBOARDING_INSTALL_APPS_PENDING = 'ONBOARDING_INSTALL_APPS_PENDING',
}
export type OnboardingKeyValueTypeMap = {
@@ -26,6 +35,7 @@ export type OnboardingKeyValueTypeMap = {
[OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING]: boolean;
[OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_PENDING]: boolean;
[OnboardingStepKeys.ONBOARDING_BOOK_ONBOARDING_PENDING]: boolean;
[OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING]: boolean;
};
@Injectable()
@@ -39,6 +49,8 @@ export class OnboardingService {
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
) {}
private isWorkspaceActivationPending(workspace: WorkspaceEntity) {
@@ -85,6 +97,9 @@ export class OnboardingService {
userVars.get(OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING) ===
true;
const isInstallAppsPending =
userVars.get(OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING) === true;
const isInviteTeamPending =
userVars.get(OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING) === true;
@@ -96,6 +111,10 @@ export class OnboardingService {
return OnboardingStatus.SYNC_EMAIL;
}
if (isInstallAppsPending) {
return OnboardingStatus.APPS_INSTALLATION;
}
if (isProfileCreationPending) {
return OnboardingStatus.PROFILE_CREATION;
}
@@ -237,6 +256,116 @@ export class OnboardingService {
}
}
async setOnboardingInstallAppsPending(
{
userId,
workspaceId,
value,
}: {
userId: string;
workspaceId: string;
value: boolean;
},
queryRunner?: QueryRunner,
) {
if (!value) {
await this.userVarsService.delete(
{
userId,
workspaceId,
key: OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING,
},
queryRunner,
);
return;
}
await this.userVarsService.set(
{
userId,
workspaceId,
key: OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING,
value: true,
},
queryRunner,
);
}
async triggerInstallAppsOnboardingStep({
userId,
workspaceId,
universalIdentifiers,
}: {
userId: string;
workspaceId: string;
universalIdentifiers: string[];
}) {
const hasClaimedInstallAppsStep = await this.claimInstallAppsOnboardingStep(
{ userId, workspaceId },
);
if (!hasClaimedInstallAppsStep) {
return;
}
const installableUniversalIdentifiers = universalIdentifiers.filter(
(universalIdentifier) =>
ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS.includes(
universalIdentifier,
),
);
if (installableUniversalIdentifiers.length === 0) {
return;
}
await this.messageQueueService.add<InstallOnboardingAppsJobData>(
INSTALL_ONBOARDING_APPS_JOB_NAME,
{ workspaceId, universalIdentifiers: installableUniversalIdentifiers },
{ id: `${INSTALL_ONBOARDING_APPS_JOB_NAME}-${workspaceId}` },
);
}
private async claimInstallAppsOnboardingStep({
userId,
workspaceId,
}: {
userId: string;
workspaceId: string;
}): Promise<boolean> {
const affectedRows = await this.userVarsService.delete({
userId,
workspaceId,
key: OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING,
});
return isDefined(affectedRows) && affectedRows > 0;
}
async creditInstallAppsReward({
workspaceId,
rewardAppsCount,
}: {
workspaceId: string;
rewardAppsCount: number;
}) {
try {
await this.billingCreditService.creditWorkspaceBalance({
workspaceId,
amountMicro:
this.twentyConfigService.get(
'ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP',
) * rewardAppsCount,
});
} catch (error) {
this.logger.error(
`Failed to credit onboarding install-apps reward for workspace ${workspaceId}`,
error,
);
}
}
async setOnboardingInviteTeamPending(
{
workspaceId,
@@ -966,6 +966,17 @@ export class ConfigVariables {
@IsOptional()
ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER = 500_000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description:
'Free credits granted per app installed during the install-apps onboarding step (in microCredits)',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsInt()
@IsOptional()
ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP = 1_000_000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Url for the frontend application',