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:
<img width="1606" height="286" alt="CleanShot 2026-06-30 at 11 24 47@2x"
src="https://github.com/user-attachments/assets/7becf8a5-04dc-4512-ac7f-951a77e4c0ac"
/>

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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22309?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-06-30 14:49:25 +02:00
committed by GitHub
parent 3d00dd4066
commit aea6c3832a
21 changed files with 294 additions and 53 deletions
@@ -1,6 +1,5 @@
export type OnboardingConfig = {
importContactsCreditsReward: number;
inviteTeamMaxCreditsReward: number;
inviteTeamCreditsRewardPerUser: number;
upgradeCreditsReward: number;
};
@@ -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}
/>
<StyledLabel>{t`Earn +${amount}`}</StyledLabel>
<StyledSuffix>
{isDefined(perUserAmount)
? t`free credits (${perUserAmount} per user)`
: t`free credits`}
</StyledSuffix>
<StyledSuffix>{suffix ?? t`free credits`}</StyledSuffix>
</StyledTag>
);
};
@@ -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 = () => {
<StyledSubtitle>
{t`Get the most out of your workspace by inviting your team.`}
</StyledSubtitle>
{isDefined(creditsReward) && (
{isDefined(creditsRewardPerUser) && (
<StyledCreditsRow>
<OnboardingCreditsRewardTag
amount={creditsReward}
perUserAmount={creditsRewardPerUser}
amount={creditsRewardPerUser}
suffix={t`free credits per user`}
/>
</StyledCreditsRow>
)}
@@ -46,7 +46,6 @@ export const mockedClientConfig: ClientConfig = {
api: { mutationMaximumAffectedRecords: 100 },
onboarding: {
importContactsCreditsReward: 2,
inviteTeamMaxCreditsReward: 9,
inviteTeamCreditsRewardPerUser: 3,
upgradeCreditsReward: 5,
},
@@ -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',
}
@@ -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,
@@ -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', {
@@ -105,6 +105,9 @@ const createSignInUpServiceForTests = () => {
insertWorkspaceEvent: jest.fn(),
}),
} as any,
{
creditWorkspaceBalance: jest.fn(),
} as any,
{
createQueryRunner: jest.fn(() => queryRunnerMock),
} as any,
@@ -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<UserEntity>,
@@ -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,
@@ -87,7 +87,6 @@ describe('ClientConfigController', () => {
},
onboarding: {
importContactsCreditsReward: 2,
inviteTeamMaxCreditsReward: 9,
inviteTeamCreditsRewardPerUser: 3,
upgradeCreditsReward: 5,
},
@@ -211,8 +211,6 @@ export class ApiConfig {
export class OnboardingConfig {
importContactsCreditsReward: number;
inviteTeamMaxCreditsReward: number;
inviteTeamCreditsRewardPerUser: number;
upgradeCreditsReward: number;
@@ -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,
},
@@ -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',
@@ -135,6 +135,19 @@ export class OnboardingService {
return OnboardingStatus.COMPLETED;
}
async isOnboardingInviteTeamPending({
workspaceId,
}: {
workspaceId: string;
}): Promise<boolean> {
return (
(await this.userVarsService.get({
workspaceId,
key: OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING,
})) === true
);
}
async setOnboardingConnectAccountPending(
{
userId,
@@ -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,
@@ -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,
];
@@ -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();
});
});
});
@@ -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<SendInvitationsDTO> {
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[],
@@ -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',
@@ -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,
);
}
@@ -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);
}