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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user