From aea6c3832a4b6081bdda2fba6e3d10b4ad6fa25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:49:25 +0200 Subject: [PATCH] Credit workspaces for onboarding invite-team signups (#22309) https://github.com/user-attachments/assets/6591cbb0-2b60-4f25-8b03-26b0da73f0d8 After the invite has been accepted: CleanShot 2026-06-30 at 11 24 47@2x Adds a dedicated `ONBOARDING_INVITATION_TOKEN` app-token type so invitations sent during the onboarding invite-team step are distinguished from regular invites. When an invited person actually signs up, the inviting workspace is credited 0.5 credits. Reward eligibility is derived entirely server-side, with no public API parameter: an invitation is reward-eligible only while the workspace is in the onboarding invite-team step (`ONBOARDING_INVITE_TEAM_PENDING`), a flag set once at workspace creation that no public mutation can re-arm. Both token types stay valid invitations everywhere via a shared `INVITATION_APP_TOKEN_TYPES`, so invitees still join normally and appear in invite lists. Crediting is a best-effort direct call to `BillingCreditService.creditWorkspaceBalance` from the sign-in-up flow: it no-ops when billing is disabled and never blocks signup, and is bounded by a 10-invite-per-workspace cap. No DB migration needed: `appToken.type` is a text column. Review in cubic --- .../client-config/types/OnboardingConfig.ts | 1 - .../OnboardingCreditsRewardTag.tsx | 11 +- .../src/pages/onboarding/InviteTeamV2.tsx | 7 +- .../src/testing/mock-data/config.ts | 1 - .../app-token/app-token.entity.ts | 1 + .../engine/core-modules/auth/auth.module.ts | 2 + .../auth/services/auth.service.ts | 8 +- .../auth/services/sign-in-up.service.spec.ts | 3 + .../auth/services/sign-in-up.service.ts | 30 +++- .../client-config.controller.spec.ts | 1 - .../client-config/client-config.entity.ts | 2 - .../services/client-config.service.spec.ts | 2 - .../services/client-config.service.ts | 5 - .../onboarding/onboarding.service.ts | 13 ++ .../twenty-config/config-variables.ts | 8 +- .../constants/invitation-app-token-types.ts | 6 + .../workspace-invitation.service.spec.ts | 133 ++++++++++++++++++ .../services/workspace-invitation.service.ts | 77 ++++++++-- ...-app-token-to-workspace-invitation.spec.ts | 23 ++- ...-app-token-to-workspace-invitation.util.ts | 10 +- .../workspace-invitation.exception.ts | 3 + 21 files changed, 294 insertions(+), 53 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types.ts diff --git a/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts b/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts index c25ceb2455..e8cffb5404 100644 --- a/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts @@ -1,6 +1,5 @@ export type OnboardingConfig = { importContactsCreditsReward: number; - inviteTeamMaxCreditsReward: number; inviteTeamCreditsRewardPerUser: number; upgradeCreditsReward: number; }; diff --git a/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx b/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx index 8b775c16fe..f322eb3836 100644 --- a/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx +++ b/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx @@ -1,6 +1,5 @@ import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; -import { isDefined } from 'twenty-shared/utils'; import { IconCoins } from 'twenty-ui/icon'; import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants'; @@ -30,12 +29,12 @@ const StyledSuffix = styled.span` type OnboardingCreditsRewardTagProps = { amount: number; - perUserAmount?: number; + suffix?: string; }; export const OnboardingCreditsRewardTag = ({ amount, - perUserAmount, + suffix, }: OnboardingCreditsRewardTagProps) => { const { t } = useLingui(); const theme = useTheme(); @@ -47,11 +46,7 @@ export const OnboardingCreditsRewardTag = ({ color={themeCssVariables.color.green9} /> {t`Earn +${amount}`} - - {isDefined(perUserAmount) - ? t`free credits (${perUserAmount} per user)` - : t`free credits`} - + {suffix ?? t`free credits`} ); }; diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx index ea6ae88aac..7978652d0e 100644 --- a/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx +++ b/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx @@ -96,7 +96,6 @@ export const InviteTeamV2 = () => { isSubmitting, } = useInviteTeam(); const onboardingConfig = useAtomStateValue(onboardingConfigState); - const creditsReward = onboardingConfig?.inviteTeamMaxCreditsReward; const creditsRewardPerUser = onboardingConfig?.inviteTeamCreditsRewardPerUser; const freeCreditsTotal = useOnboardingFreeCreditsTotal(); @@ -108,11 +107,11 @@ export const InviteTeamV2 = () => { {t`Get the most out of your workspace by inviting your team.`} - {isDefined(creditsReward) && ( + {isDefined(creditsRewardPerUser) && ( )} diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index f535dec1e8..6811b54678 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -46,7 +46,6 @@ export const mockedClientConfig: ClientConfig = { api: { mutationMaximumAffectedRecords: 100 }, onboarding: { importContactsCreditsReward: 2, - inviteTeamMaxCreditsReward: 9, inviteTeamCreditsRewardPerUser: 3, upgradeCreditsReward: 5, }, diff --git a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts index 43f698c1c7..f0b4d6bd86 100644 --- a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts @@ -26,6 +26,7 @@ export enum AppTokenType { AuthorizationCode = 'AUTHORIZATION_CODE', PasswordResetToken = 'PASSWORD_RESET_TOKEN', InvitationToken = 'INVITATION_TOKEN', + OnboardingInvitationToken = 'ONBOARDING_INVITATION_TOKEN', EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN', EnterpriseValidityToken = 'ENTERPRISE_VALIDITY_TOKEN', } diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 0b9b7f3744..b70a89241f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -35,6 +35,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; +import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module'; import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain-manager/subdomain-manager.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; @@ -126,6 +127,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; CoreEntityCacheModule, SecureHttpClientModule, EnterpriseModule, + BillingModule, FileModule, ConnectedAccountTokenEncryptionModule, EmailAliasManagerModule, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 85032cee24..3132a73cab 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -18,6 +18,7 @@ import { AppTokenEntity, AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; +import { INVITATION_APP_TOKEN_TYPES } from 'src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation'; @@ -796,9 +797,10 @@ export class AuthService { .where('"appToken"."workspaceId" = :workspaceId', { workspaceId: params.currentWorkspace.id, }) - .andWhere('"appToken".type = :type', { - type: AppTokenType.InvitationToken, - }); + .andWhere('"appToken".type IN (:...types)', { + types: INVITATION_APP_TOKEN_TYPES, + }) + .andWhere('"appToken"."deletedAt" IS NULL'); if ('workspacePersonalInviteToken' in params) { qr.andWhere('"appToken".value = :personalInviteToken', { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index aaed56f83d..658e67d1bc 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -105,6 +105,9 @@ const createSignInUpServiceForTests = () => { insertWorkspaceEvent: jest.fn(), }), } as any, + { + creditWorkspaceBalance: jest.fn(), + } as any, { createQueryRunner: jest.fn(() => queryRunnerMock), } as any, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 6365d03336..e804b06d9e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; @@ -19,8 +19,12 @@ import { type QueryFailedErrorWithCode } from 'src/engine/api/graphql/workspace- import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; import { USER_SIGNUP_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/user/user-signup'; import { WORKSPACE_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/workspace/workspace-created'; -import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { + type AppTokenEntity, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service'; import { AuthException, AuthExceptionCode, @@ -68,6 +72,8 @@ import { isWorkEmail } from 'src/utils/is-work-email'; @Injectable() // oxlint-disable-next-line twenty/inject-workspace-repository export class SignInUpService { + private readonly logger = new Logger(SignInUpService.name); + constructor( @InjectRepository(UserEntity) private readonly userRepository: Repository, @@ -86,6 +92,7 @@ export class SignInUpService { private readonly fileCorePictureService: FileCorePictureService, private readonly enterprisePlanService: EnterprisePlanService, private readonly eventLogEmitterService: EventLogEmitterService, + private readonly billingCreditService: BillingCreditService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -231,6 +238,25 @@ export class SignInUpService { roleId: params.invitation.context?.roleId, }); + if ( + params.invitation.type === AppTokenType.OnboardingInvitationToken && + params.userData.type === 'newUserWithPicture' + ) { + try { + await this.billingCreditService.creditWorkspaceBalance({ + workspaceId: invitationValidation.workspace.id, + amountMicro: this.twentyConfigService.get( + 'ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER', + ), + }); + } catch (error) { + this.logger.error( + `Failed to credit onboarding invite reward for workspace ${invitationValidation.workspace.id}`, + error, + ); + } + } + await this.workspaceInvitationService.invalidateWorkspaceInvitation( invitationValidation.workspace.id, email, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 3fae6da4a5..16b761f5ce 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -87,7 +87,6 @@ describe('ClientConfigController', () => { }, onboarding: { importContactsCreditsReward: 2, - inviteTeamMaxCreditsReward: 9, inviteTeamCreditsRewardPerUser: 3, upgradeCreditsReward: 5, }, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 24d17e0b42..35588a763a 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -211,8 +211,6 @@ export class ApiConfig { export class OnboardingConfig { importContactsCreditsReward: number; - inviteTeamMaxCreditsReward: number; - inviteTeamCreditsRewardPerUser: number; upgradeCreditsReward: number; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index 4172ec547f..b38463f728 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -93,7 +93,6 @@ describe('ClientConfigService', () => { CAPTCHA_SITE_KEY: 'site-key-123', MUTATION_MAXIMUM_AFFECTED_RECORDS: 1000, ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD: 2_000_000, - ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD: 9_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, IS_ATTACHMENT_PREVIEW_ENABLED: true, @@ -171,7 +170,6 @@ describe('ClientConfigService', () => { }, onboarding: { importContactsCreditsReward: 2, - inviteTeamMaxCreditsReward: 9, inviteTeamCreditsRewardPerUser: 3, upgradeCreditsReward: 5, }, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index 274de60dbf..e020db84b0 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -225,11 +225,6 @@ export class ClientConfigService { 'ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD', ), ), - inviteTeamMaxCreditsReward: toDisplayCredits( - this.twentyConfigService.get( - 'ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD', - ), - ), inviteTeamCreditsRewardPerUser: toDisplayCredits( this.twentyConfigService.get( 'ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER', 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 da6ac48c1e..9fa16d1bb5 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 @@ -135,6 +135,19 @@ export class OnboardingService { return OnboardingStatus.COMPLETED; } + async isOnboardingInviteTeamPending({ + workspaceId, + }: { + workspaceId: string; + }): Promise { + return ( + (await this.userVarsService.get({ + workspaceId, + key: OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING, + })) === true + ); + } + async setOnboardingConnectAccountPending( { userId, diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index d0274845c7..27a38b9eb1 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -947,24 +947,24 @@ export class ConfigVariables { @ConfigVariablesMetadata({ group: ConfigVariablesGroup.BILLING_CONFIG, description: - 'Maximum free credits granted for completing the invite-team onboarding step (in microCredits)', + 'Maximum number of invitations that grant credits during the invite-team onboarding step', type: ConfigVariableType.NUMBER, }) @CastToPositiveNumber() @IsInt() @IsOptional() - ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD = 9_000_000; + ONBOARDING_INVITE_TEAM_MAX_INVITES = 10; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.BILLING_CONFIG, description: - 'Free credits granted per user invited during the invite-team onboarding step (in microCredits)', + 'Free credits granted per user invited during the invite-team onboarding step who signs up (in microCredits)', type: ConfigVariableType.NUMBER, }) @CastToPositiveNumber() @IsInt() @IsOptional() - ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER = 3_000_000; + ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER = 500_000; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.SERVER_CONFIG, diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types.ts new file mode 100644 index 0000000000..b15201bfcb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types.ts @@ -0,0 +1,6 @@ +import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity'; + +export const INVITATION_APP_TOKEN_TYPES: readonly AppTokenType[] = [ + AppTokenType.InvitationToken, + AppTokenType.OnboardingInvitationToken, +]; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index 1e8f0607ce..fc0193213d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -92,6 +92,7 @@ describe('WorkspaceInvitationService', () => { useValue: { setOnboardingInviteTeamPending: jest.fn(), setOnboardingBookOnboardingPending: jest.fn(), + isOnboardingInviteTeamPending: jest.fn().mockResolvedValue(false), }, }, { @@ -233,5 +234,137 @@ describe('WorkspaceInvitationService', () => { value: true, }); }); + + it('should mint reward-eligible tokens when the invite-team step is pending', async () => { + const workspace = { + id: 'workspace-id', + inviteHash: 'invite-hash', + displayName: 'Test Workspace', + } as WorkspaceEntity; + const sender = { + userEmail: 'sender@example.com', + name: { firstName: 'Sender' }, + locale: 'en', + }; + + const createWorkspaceInvitationSpy = jest + .spyOn(service, 'createWorkspaceInvitation') + .mockResolvedValue({ + context: { email: 'test1@example.com' }, + value: 'token-value', + type: AppTokenType.OnboardingInvitationToken, + } as AppTokenEntity); + jest + .spyOn(onboardingService, 'isOnboardingInviteTeamPending') + .mockResolvedValue(true); + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: any) => + key === 'ONBOARDING_INVITE_TEAM_MAX_INVITES' + ? 10 + : 'http://localhost:3000', + ); + jest.spyOn(appTokenRepository, 'count').mockResolvedValue(0); + jest.spyOn(emailService, 'send').mockResolvedValue({} as any); + + await service.sendInvitations( + ['test1@example.com'], + workspace, + sender as WorkspaceMemberWorkspaceEntity, + ); + + expect(createWorkspaceInvitationSpy).toHaveBeenCalledWith( + 'test1@example.com', + workspace, + undefined, + true, + ); + }); + + it('should downgrade to a regular invitation when the invite-team step is not pending', async () => { + const workspace = { + id: 'workspace-id', + inviteHash: 'invite-hash', + displayName: 'Test Workspace', + } as WorkspaceEntity; + const sender = { + userEmail: 'sender@example.com', + name: { firstName: 'Sender' }, + locale: 'en', + }; + + const createWorkspaceInvitationSpy = jest + .spyOn(service, 'createWorkspaceInvitation') + .mockResolvedValue({ + context: { email: 'test1@example.com' }, + value: 'token-value', + type: AppTokenType.InvitationToken, + } as AppTokenEntity); + jest + .spyOn(onboardingService, 'isOnboardingInviteTeamPending') + .mockResolvedValue(false); + jest + .spyOn(twentyConfigService, 'get') + .mockReturnValue('http://localhost:3000'); + jest.spyOn(emailService, 'send').mockResolvedValue({} as any); + + await service.sendInvitations( + ['test1@example.com'], + workspace, + sender as WorkspaceMemberWorkspaceEntity, + ); + + expect(createWorkspaceInvitationSpy).toHaveBeenCalledWith( + 'test1@example.com', + workspace, + undefined, + false, + ); + expect( + onboardingService.isOnboardingInviteTeamPending, + ).toHaveBeenCalledWith({ workspaceId: workspace.id }); + }); + }); + + describe('resendWorkspaceInvitation', () => { + it('should preserve onboarding eligibility when resending an onboarding invitation', async () => { + const workspace = { + id: 'workspace-id', + inviteHash: 'invite-hash', + displayName: 'Test Workspace', + } as WorkspaceEntity; + const sender = { + userEmail: 'sender@example.com', + name: { firstName: 'Sender' }, + locale: 'en', + }; + + jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue({ + id: 'app-token-id', + type: AppTokenType.OnboardingInvitationToken, + context: { email: 'test1@example.com' }, + } as AppTokenEntity); + jest.spyOn(appTokenRepository, 'delete').mockResolvedValue({} as any); + const sendInvitationsSpy = jest + .spyOn(service, 'sendInvitations') + .mockResolvedValue({ success: true, errors: [], result: [] }); + + await service.resendWorkspaceInvitation( + 'app-token-id', + workspace, + sender as WorkspaceMemberWorkspaceEntity, + ); + + expect(sendInvitationsSpy).toHaveBeenCalledWith( + ['test1@example.com'], + workspace, + sender, + undefined, + true, + ); + expect( + onboardingService.isOnboardingInviteTeamPending, + ).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index d4754d936e..bac74aec16 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -10,12 +10,13 @@ import ms from 'ms'; import { SendInviteLinkEmail } from 'twenty-emails'; import { AppPath, FileFolder } from 'twenty-shared/types'; import { getAppPath, isDefined } from 'twenty-shared/utils'; -import { IsNull, Repository } from 'typeorm'; +import { In, IsNull, Repository } from 'typeorm'; import { AppTokenEntity, AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; +import { INVITATION_APP_TOKEN_TYPES } from 'src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types'; import { AuthException, AuthExceptionCode, @@ -67,7 +68,7 @@ export class WorkspaceInvitationService { const appToken = await this.appTokenRepository.findOne({ where: { value: workspacePersonalInviteToken, - type: AppTokenType.InvitationToken, + type: In(INVITATION_APP_TOKEN_TYPES), }, relations: { workspace: true }, }); @@ -97,8 +98,8 @@ export class WorkspaceInvitationService { return await this.appTokenRepository .createQueryBuilder('appToken') .innerJoinAndSelect('appToken.workspace', 'workspace') - .where('"appToken".type = :type', { - type: AppTokenType.InvitationToken, + .where('"appToken".type IN (:...types)', { + types: INVITATION_APP_TOKEN_TYPES, }) .andWhere('"appToken".context->>\'email\' = :email', { email }) .andWhere('appToken.deletedAt IS NULL') @@ -114,8 +115,8 @@ export class WorkspaceInvitationService { .where('"appToken"."workspaceId" = :workspaceId', { workspaceId, }) - .andWhere('"appToken".type = :type', { - type: AppTokenType.InvitationToken, + .andWhere('"appToken".type IN (:...types)', { + types: INVITATION_APP_TOKEN_TYPES, }) .andWhere('"appToken".context->>\'email\' = :email', { email }) .getOne(); @@ -125,7 +126,7 @@ export class WorkspaceInvitationService { const appToken = await this.appTokenRepository.findOne({ where: { value: invitationToken, - type: AppTokenType.InvitationToken, + type: In(INVITATION_APP_TOKEN_TYPES), }, relations: { workspace: true }, }); @@ -144,7 +145,7 @@ export class WorkspaceInvitationService { const appTokens = await this.appTokenRepository.find({ where: { workspaceId: workspace.id, - type: AppTokenType.InvitationToken, + type: In(INVITATION_APP_TOKEN_TYPES), deletedAt: IsNull(), }, select: { @@ -159,6 +160,7 @@ export class WorkspaceInvitationService { email: string, workspace: WorkspaceEntity, roleId?: string, + isOnboardingInvitation = false, ) { const maybeWorkspaceInvitation = await this.getOneWorkspaceInvitation( workspace.id, @@ -191,7 +193,12 @@ export class WorkspaceInvitationService { ); } - return this.generateInvitationToken(workspace.id, email, roleId); + return this.generateInvitationToken( + workspace.id, + email, + roleId, + isOnboardingInvitation, + ); } async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) { @@ -199,7 +206,7 @@ export class WorkspaceInvitationService { where: { id: appTokenId, workspaceId, - type: AppTokenType.InvitationToken, + type: In(INVITATION_APP_TOKEN_TYPES), }, }); @@ -231,7 +238,7 @@ export class WorkspaceInvitationService { where: { id: appTokenId, workspaceId: workspace.id, - type: AppTokenType.InvitationToken, + type: In(INVITATION_APP_TOKEN_TYPES), }, }); @@ -249,6 +256,7 @@ export class WorkspaceInvitationService { workspace, sender, appToken.context.roleId, + appToken.type === AppTokenType.OnboardingInvitationToken, ); } @@ -257,6 +265,7 @@ export class WorkspaceInvitationService { workspace: WorkspaceEntity, sender: WorkspaceMemberWorkspaceEntity, roleId?: string, + isOnboardingInviteRewardOverride?: boolean, ): Promise { if (!workspace?.inviteHash) { return { @@ -273,6 +282,19 @@ export class WorkspaceInvitationService { ); } + const isOnboardingInviteReward = + isOnboardingInviteRewardOverride ?? + (await this.onboardingService.isOnboardingInviteTeamPending({ + workspaceId: workspace.id, + })); + + if (isOnboardingInviteReward) { + await this.throwIfOnboardingInvitationLimitReached( + workspace.id, + emails.length, + ); + } + await this.throttleInvitationSending(workspace.id, emails); const invitationResults = await Promise.allSettled( @@ -281,6 +303,7 @@ export class WorkspaceInvitationService { email, workspace, roleId, + isOnboardingInviteReward, ); if (!appToken.context?.email) { @@ -403,6 +426,7 @@ export class WorkspaceInvitationService { workspaceId: string, email: string, roleId?: string, + isOnboardingInvitation = false, ) { const expiresIn = this.twentyConfigService.get( 'INVITATION_TOKEN_EXPIRES_IN', @@ -420,7 +444,9 @@ export class WorkspaceInvitationService { const invitationToken = this.appTokenRepository.create({ workspaceId, expiresAt, - type: AppTokenType.InvitationToken, + type: isOnboardingInvitation + ? AppTokenType.OnboardingInvitationToken + : AppTokenType.InvitationToken, value: crypto.randomBytes(32).toString('hex'), context: { email, @@ -431,6 +457,33 @@ export class WorkspaceInvitationService { return this.appTokenRepository.save(invitationToken); } + private async throwIfOnboardingInvitationLimitReached( + workspaceId: string, + requestedCount: number, + ) { + const maxOnboardingInvitations = this.twentyConfigService.get( + 'ONBOARDING_INVITE_TEAM_MAX_INVITES', + ); + + const existingOnboardingInvitations = await this.appTokenRepository.count({ + where: { + workspaceId, + type: AppTokenType.OnboardingInvitationToken, + deletedAt: IsNull(), + }, + }); + + if ( + existingOnboardingInvitations + requestedCount > + maxOnboardingInvitations + ) { + throw new WorkspaceInvitationException( + `Onboarding invitation limit (${maxOnboardingInvitations}) reached for workspace ${workspaceId}`, + WorkspaceInvitationExceptionCode.TOO_MANY_ONBOARDING_INVITATIONS, + ); + } + } + private async throttleInvitationSending( workspaceId: string, emails: string[], diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts index e1869822e9..bd47cb38d3 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.spec.ts @@ -2,6 +2,7 @@ import { type AppTokenEntity, AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; +import { INVITATION_APP_TOKEN_TYPES } from 'src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types'; import { WorkspaceInvitationException, WorkspaceInvitationExceptionCode, @@ -10,7 +11,7 @@ import { import { castAppTokenToWorkspaceInvitationUtil } from './cast-app-token-to-workspace-invitation.util'; describe('castAppTokenToWorkspaceInvitation', () => { - it('should throw an error if token type is not InvitationToken', () => { + it('should throw an error if token type is not an invitation token', () => { const appToken = { id: '1', type: AppTokenType.RefreshToken, @@ -20,12 +21,30 @@ describe('castAppTokenToWorkspaceInvitation', () => { expect(() => castAppTokenToWorkspaceInvitationUtil(appToken)).toThrowError( new WorkspaceInvitationException( - `Token type must be "${AppTokenType.InvitationToken}"`, + `Token type must be one of "${INVITATION_APP_TOKEN_TYPES.join('", "')}"`, WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE, ), ); }); + it('should return the invitation object for an onboarding invitation token', () => { + const appToken = { + id: '1', + type: AppTokenType.OnboardingInvitationToken, + context: { email: 'test@example.com' }, + expiresAt: new Date(), + } as AppTokenEntity; + + const invitation = castAppTokenToWorkspaceInvitationUtil(appToken); + + expect(invitation).toEqual({ + id: '1', + email: 'test@example.com', + roleId: null, + expiresAt: appToken.expiresAt, + }); + }); + it('should throw an error if context email is missing', () => { const appToken = { id: '1', diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts index e89b1f3291..934f8e3d02 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util.ts @@ -1,7 +1,5 @@ -import { - type AppTokenEntity, - AppTokenType, -} from 'src/engine/core-modules/app-token/app-token.entity'; +import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { INVITATION_APP_TOKEN_TYPES } from 'src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types'; import { WorkspaceInvitationException, WorkspaceInvitationExceptionCode, @@ -10,9 +8,9 @@ import { export const castAppTokenToWorkspaceInvitationUtil = ( appToken: AppTokenEntity, ) => { - if (appToken.type !== AppTokenType.InvitationToken) { + if (!INVITATION_APP_TOKEN_TYPES.includes(appToken.type)) { throw new WorkspaceInvitationException( - `Token type must be "${AppTokenType.InvitationToken}"`, + `Token type must be one of "${INVITATION_APP_TOKEN_TYPES.join('", "')}"`, WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE, ); } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts index 1df15537db..4b841fb7e9 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.exception.ts @@ -11,6 +11,7 @@ export enum WorkspaceInvitationExceptionCode { USER_ALREADY_EXIST = 'USER_ALREADY_EXIST', INVALID_INVITATION = 'INVALID_INVITATION', EMAIL_MISSING = 'EMAIL_MISSING', + TOO_MANY_ONBOARDING_INVITATIONS = 'TOO_MANY_ONBOARDING_INVITATIONS', } const getWorkspaceInvitationExceptionUserFriendlyMessage = ( @@ -27,6 +28,8 @@ const getWorkspaceInvitationExceptionUserFriendlyMessage = ( return msg`This user is already a member of the workspace.`; case WorkspaceInvitationExceptionCode.EMAIL_MISSING: return msg`Email is required.`; + case WorkspaceInvitationExceptionCode.TOO_MANY_ONBOARDING_INVITATIONS: + return msg`You have reached the maximum number of invitations for this step.`; default: assertUnreachable(code); }