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:
@@ -1,6 +1,5 @@
|
||||
export type OnboardingConfig = {
|
||||
importContactsCreditsReward: number;
|
||||
inviteTeamMaxCreditsReward: number;
|
||||
inviteTeamCreditsRewardPerUser: number;
|
||||
upgradeCreditsReward: number;
|
||||
};
|
||||
|
||||
+3
-8
@@ -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', {
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
-1
@@ -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;
|
||||
|
||||
-2
@@ -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,
|
||||
},
|
||||
|
||||
-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,
|
||||
|
||||
+6
@@ -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,
|
||||
];
|
||||
+133
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+65
-12
@@ -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[],
|
||||
|
||||
+21
-2
@@ -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',
|
||||
|
||||
+4
-6
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user