diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 853b5ab960..befcfa78aa 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -112,10 +112,9 @@ export class GoogleAPIsAuthController { }); if (userId) { - await this.onboardingService.setOnboardingConnectAccountPending({ + await this.onboardingService.completeOnboardingConnectAccountStep({ userId, workspaceId, - value: false, }); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts index 827666d0dc..fdd9842258 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts @@ -119,10 +119,9 @@ export class MicrosoftAPIsAuthController { }); if (userId) { - await this.onboardingService.setOnboardingConnectAccountPending({ + await this.onboardingService.completeOnboardingConnectAccountStep({ userId, workspaceId, - value: false, }); } diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts index 04092ab028..e3ab5adb1c 100644 --- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts +++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts @@ -132,12 +132,12 @@ export class KeyValuePairService< key, }; - if (queryRunner) { - await queryRunner.manager - .getRepository(KeyValuePairEntity) - .delete(deleteConditions); - } else { - await this.keyValuePairRepository.delete(deleteConditions); - } + const { affected } = queryRunner + ? await queryRunner.manager + .getRepository(KeyValuePairEntity) + .delete(deleteConditions) + : await this.keyValuePairRepository.delete(deleteConditions); + + return affected; } } 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 new file mode 100644 index 0000000000..9f61719a7c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts @@ -0,0 +1,139 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +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 { + OnboardingService, + OnboardingStepKeys, +} 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +describe('OnboardingService', () => { + let service: OnboardingService; + let userVarsService: UserVarsService; + let billingCreditService: BillingCreditService; + let twentyConfigService: TwentyConfigService; + + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OnboardingService, + { + provide: BillingService, + useValue: { + isSubscriptionIncompleteOnboardingStatus: jest.fn(), + }, + }, + { + provide: BillingCreditService, + useValue: { + creditWorkspaceBalance: jest.fn(), + }, + }, + { + provide: UserVarsService, + useValue: { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: getRepositoryToken(WorkspaceEntity), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(OnboardingService); + userVarsService = module.get(UserVarsService); + billingCreditService = + module.get(BillingCreditService); + twentyConfigService = module.get(TwentyConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('completeOnboardingConnectAccountStep', () => { + it('should credit the import-contacts reward when the step was claimed', async () => { + jest.spyOn(userVarsService, 'delete').mockResolvedValue(1); + jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000); + + await service.completeOnboardingConnectAccountStep({ + userId, + workspaceId, + }); + + expect(userVarsService.delete).toHaveBeenCalledWith({ + userId, + workspaceId, + key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING, + }); + expect(billingCreditService.creditWorkspaceBalance).toHaveBeenCalledWith({ + workspaceId, + amountMicro: 2_000_000, + }); + }); + + it('should not credit anything when the step was already consumed', async () => { + jest.spyOn(userVarsService, 'delete').mockResolvedValue(0); + + await service.completeOnboardingConnectAccountStep({ + userId, + workspaceId, + }); + + expect( + billingCreditService.creditWorkspaceBalance, + ).not.toHaveBeenCalled(); + }); + + it('should credit only once when two completions race for the same step', async () => { + jest + .spyOn(userVarsService, 'delete') + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0); + jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000); + + await Promise.all([ + service.completeOnboardingConnectAccountStep({ userId, workspaceId }), + service.completeOnboardingConnectAccountStep({ userId, workspaceId }), + ]); + + expect(billingCreditService.creditWorkspaceBalance).toHaveBeenCalledTimes( + 1, + ); + }); + + it('should not throw when crediting fails', async () => { + jest.spyOn(userVarsService, 'delete').mockResolvedValue(1); + jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000); + jest + .spyOn(billingCreditService, 'creditWorkspaceBalance') + .mockRejectedValue(new Error('billing failure')); + + await expect( + service.completeOnboardingConnectAccountStep({ + userId, + workspaceId, + }), + ).resolves.not.toThrow(); + }); + }); +}); 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 c751e46aa7..da6ac48c1e 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 @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isNonEmptyString } from '@sniptt/guards'; @@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; 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 { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -29,8 +30,11 @@ export type OnboardingKeyValueTypeMap = { @Injectable() export class OnboardingService { + private readonly logger = new Logger(OnboardingService.name); + constructor( private readonly billingService: BillingService, + private readonly billingCreditService: BillingCreditService, private readonly userVarsService: UserVarsService, private readonly twentyConfigService: TwentyConfigService, @InjectRepository(WorkspaceEntity) @@ -167,6 +171,59 @@ export class OnboardingService { ); } + async completeOnboardingConnectAccountStep({ + userId, + workspaceId, + }: { + userId: string; + workspaceId: string; + }) { + const hasClaimedConnectAccountStep = + await this.claimOnboardingConnectAccountStep({ userId, workspaceId }); + + if (!hasClaimedConnectAccountStep) { + return; + } + + await this.creditImportContactsReward({ workspaceId }); + } + + private async claimOnboardingConnectAccountStep({ + userId, + workspaceId, + }: { + userId: string; + workspaceId: string; + }): Promise { + const affectedRows = await this.userVarsService.delete({ + userId, + workspaceId, + key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING, + }); + + return isDefined(affectedRows) && affectedRows > 0; + } + + private async creditImportContactsReward({ + workspaceId, + }: { + workspaceId: string; + }) { + try { + await this.billingCreditService.creditWorkspaceBalance({ + workspaceId, + amountMicro: this.twentyConfigService.get( + 'ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD', + ), + }); + } catch (error) { + this.logger.error( + `Failed to credit onboarding import-contacts reward for workspace ${workspaceId}`, + error, + ); + } + } + async setOnboardingInviteTeamPending( { workspaceId,