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:
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.
---
.../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);
}