5095 move onboardingstatus computation from frontend to backend (#5954)

- move front `onboardingStatus` computing to server side
- add logic to `useSetNextOnboardingStatus`
- update some missing redirections in
`usePageChangeEffectNavigateLocation`
- separate subscriptionStatus from onboardingStatus
This commit is contained in:
martmull
2024-06-28 17:32:02 +02:00
committed by GitHub
parent 1a66db5bff
commit b8f33f6f59
78 changed files with 1767 additions and 1763 deletions

View File

@ -30,6 +30,7 @@ import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/stan
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { AuthResolver } from './auth.resolver';
@ -65,6 +66,7 @@ const jwtModule = JwtModule.registerAsync({
]),
HttpModule,
UserWorkspaceModule,
WorkspaceModule,
OnboardingModule,
TwentyORMModule.forFeature([CalendarChannelWorkspaceEntity]),
WorkspaceDataSourceModule,

View File

@ -8,6 +8,7 @@ import { EnvironmentService } from 'src/engine/integrations/environment/environm
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
describe('SignInUpService', () => {
let service: SignInUpService;
@ -40,6 +41,10 @@ describe('SignInUpService', () => {
provide: HttpService,
useValue: {},
},
{
provide: WorkspaceService,
useValue: {},
},
],
}).compile();

View File

@ -24,6 +24,7 @@ import { FileUploadService } from 'src/engine/core-modules/file/file-upload/serv
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { getImageBufferFromUrl } from 'src/utils/image';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
export type SignInUpServiceInput = {
email: string;
@ -44,6 +45,7 @@ export class SignInUpService {
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly workspaceService: WorkspaceService,
private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService,
) {}
@ -142,10 +144,12 @@ export class SignInUpService {
ForbiddenException,
);
const isWorkspaceActivated =
await this.workspaceService.isWorkspaceActivated(workspace.id);
assert(
!this.environmentService.get('IS_BILLING_ENABLED') ||
workspace.subscriptionStatus !== 'incomplete',
'Workspace subscription status is incomplete',
isWorkspaceActivated,
'Workspace is not ready to welcome new members',
ForbiddenException,
);
@ -199,7 +203,6 @@ export class SignInUpService {
displayName: '',
domainName: '',
inviteHash: v4(),
subscriptionStatus: 'incomplete',
});
const workspace = await this.workspaceRepository.save(workspaceToCreate);

View File

@ -10,13 +10,19 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@Module({
imports: [
StripeModule,
UserWorkspaceModule,
TypeOrmModule.forFeature(
[BillingSubscription, BillingSubscriptionItem, Workspace],
[
BillingSubscription,
BillingSubscriptionItem,
Workspace,
FeatureFlagEntity,
],
'core',
),
],

View File

@ -2,17 +2,25 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Not, Repository } from 'typeorm';
import { In, Not, Repository } from 'typeorm';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import {
BillingSubscription,
SubscriptionInterval,
SubscriptionStatus,
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { assert } from 'src/utils/assert';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import {
FeatureFlagEntity,
FeatureFlagKeys,
} from 'src/engine/core-modules/feature-flag/feature-flag.entity';
export enum AvailableProduct {
BasePlan = 'base-plan',
@ -34,12 +42,45 @@ export class BillingService {
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
async getActiveSubscriptionWorkspaceIds() {
return (
await this.workspaceRepository.find({
where: this.environmentService.get('IS_BILLING_ENABLED')
? {
currentBillingSubscription: {
status: In([
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
]),
},
}
: {},
select: ['id'],
})
).map((workspace) => workspace.id);
}
async isBillingEnabledForWorkspace(workspaceId: string) {
const isFreeAccessEnabled = await this.featureFlagRepository.findOneBy({
workspaceId,
key: FeatureFlagKeys.IsFreeAccessEnabled,
value: true,
});
return (
!isFreeAccessEnabled && this.environmentService.get('IS_BILLING_ENABLED')
);
}
getProductStripeId(product: AvailableProduct) {
if (product === AvailableProduct.BasePlan) {
return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID');
@ -84,13 +125,13 @@ export class BillingService {
}) {
const notCanceledSubscriptions =
await this.billingSubscriptionRepository.find({
where: { ...criteria, status: Not('canceled') },
where: { ...criteria, status: Not(SubscriptionStatus.Canceled) },
relations: ['billingSubscriptionItems'],
});
assert(
notCanceledSubscriptions.length <= 1,
`More than on not canceled subscription for workspace ${criteria.workspaceId}`,
`More than one not canceled subscription for workspace ${criteria.workspaceId}`,
);
return notCanceledSubscriptions?.[0];
@ -171,7 +212,9 @@ export class BillingService {
workspaceId: user.defaultWorkspaceId,
});
const newInterval =
billingSubscription?.interval === 'year' ? 'month' : 'year';
billingSubscription?.interval === SubscriptionInterval.Year
? SubscriptionInterval.Month
: SubscriptionInterval.Year;
const billingSubscriptionItem = await this.getBillingSubscriptionItem(
user.defaultWorkspaceId,
);
@ -265,10 +308,6 @@ export class BillingService {
return;
}
await this.workspaceRepository.update(workspaceId, {
subscriptionStatus: data.object.status,
});
await this.billingSubscriptionRepository.upsert(
{
workspaceId: workspaceId,
@ -302,5 +341,10 @@ export class BillingService {
skipUpdateIfNoValuesChanged: true,
},
);
await this.featureFlagRepository.delete({
workspaceId,
key: FeatureFlagKeys.IsFreeAccessEnabled,
});
}
}

View File

@ -3,9 +3,11 @@ import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import Stripe from 'stripe';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ArgsType()
export class CheckoutSessionInput {
@Field(() => String)
@Field(() => SubscriptionInterval)
@IsString()
@IsNotEmpty()
recurringInterval: Stripe.Price.Recurring.Interval;

View File

@ -2,9 +2,11 @@ import { Field, ObjectType } from '@nestjs/graphql';
import Stripe from 'stripe';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ObjectType()
export class ProductPriceEntity {
@Field(() => String)
@Field(() => SubscriptionInterval)
recurringInterval: Stripe.Price.Recurring.Interval;
@Field(() => Number)

View File

@ -1,4 +1,4 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import {
Column,
@ -18,6 +18,27 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
export enum SubscriptionStatus {
Active = 'active',
Canceled = 'canceled',
Incomplete = 'incomplete',
IncompleteExpired = 'incomplete_expired',
PastDue = 'past_due',
Paused = 'paused',
Trialing = 'trialing',
Unpaid = 'unpaid',
}
export enum SubscriptionInterval {
Day = 'day',
Month = 'month',
Week = 'week',
Year = 'year',
}
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' });
@Entity({ name: 'billingSubscription', schema: 'core' })
@ObjectType('BillingSubscription')
export class BillingSubscription {
@ -49,12 +70,20 @@ export class BillingSubscription {
@Column({ unique: true, nullable: false })
stripeSubscriptionId: string;
@Field(() => String)
@Column({ type: 'text', nullable: false })
@Field(() => SubscriptionStatus)
@Column({
type: 'enum',
enum: Object.values(SubscriptionStatus),
nullable: false,
})
status: Stripe.Subscription.Status;
@Field(() => String, { nullable: true })
@Column({ type: 'text', nullable: true })
@Field(() => SubscriptionInterval, { nullable: true })
@Column({
type: 'enum',
enum: Object.values(SubscriptionInterval),
nullable: true,
})
interval: Stripe.Price.Recurring.Interval;
@OneToMany(

View File

@ -9,15 +9,15 @@ import {
UpdateSubscriptionJob,
UpdateSubscriptionJobData,
} from 'src/engine/core-modules/billing/jobs/update-subscription.job';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
@Injectable()
export class BillingWorkspaceMemberListener {
constructor(
@InjectMessageQueue(MessageQueue.billingQueue)
private readonly messageQueueService: MessageQueueService,
private readonly environmentService: EnvironmentService,
private readonly billingService: BillingService,
) {}
@OnEvent('workspaceMember.created')
@ -25,7 +25,12 @@ export class BillingWorkspaceMemberListener {
async handleCreateOrDeleteEvent(
payload: ObjectRecordCreateEvent<WorkspaceMemberWorkspaceEntity>,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
const isBillingEnabledForWorkspace =
await this.billingService.isBillingEnabledForWorkspace(
payload.workspaceId,
);
if (!isBillingEnabledForWorkspace) {
return;
}

View File

@ -23,6 +23,7 @@ export enum FeatureFlagKeys {
IsStripeIntegrationEnabled = 'IS_STRIPE_INTEGRATION_ENABLED',
IsContactCreationForSentAndReceivedEmailsEnabled = 'IS_CONTACT_CREATION_FOR_SENT_AND_RECEIVED_EMAILS_ENABLED',
IsGoogleCalendarSyncV2Enabled = 'IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED',
IsFreeAccessEnabled = 'IS_FREE_ACCESS_ENABLED',
}
@Entity({ name: 'featureFlag', schema: 'core' })

View File

@ -0,0 +1,8 @@
export enum OnboardingStatus {
PLAN_REQUIRED = 'PLAN_REQUIRED',
WORKSPACE_ACTIVATION = 'WORKSPACE_ACTIVATION',
PROFILE_CREATION = 'PROFILE_CREATION',
SYNC_EMAIL = 'SYNC_EMAIL',
INVITE_TEAM = 'INVITE_TEAM',
COMPLETED = 'COMPLETED',
}

View File

@ -1,4 +0,0 @@
export enum OnboardingStep {
SYNC_EMAIL = 'SYNC_EMAIL',
INVITE_TEAM = 'INVITE_TEAM',
}

View File

@ -5,9 +5,19 @@ import { OnboardingResolver } from 'src/engine/core-modules/onboarding/onboardin
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { EnvironmentModule } from 'src/engine/integrations/environment/environment.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
@Module({
imports: [DataSourceModule, UserWorkspaceModule, KeyValuePairModule],
imports: [
DataSourceModule,
WorkspaceManagerModule,
UserWorkspaceModule,
KeyValuePairModule,
EnvironmentModule,
BillingModule,
],
exports: [OnboardingService],
providers: [OnboardingService, OnboardingResolver],
})

View File

@ -1,13 +1,20 @@
import { Injectable } from '@nestjs/common';
import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service';
import { OnboardingStep } from 'src/engine/core-modules/onboarding/enums/onboarding-step.enum';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { InjectWorkspaceRepository } from 'src/engine/twenty-orm/decorators/inject-workspace-repository.decorator';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { isDefined } from 'src/utils/is-defined';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
enum OnboardingStepValues {
SKIPPED = 'SKIPPED',
@ -26,29 +33,71 @@ type OnboardingKeyValueType = {
@Injectable()
export class OnboardingService {
constructor(
private readonly billingService: BillingService,
private readonly workspaceManagerService: WorkspaceManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly keyValuePairService: KeyValuePairService<OnboardingKeyValueType>,
@InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity)
private readonly connectedAccountRepository: ConnectedAccountRepository,
@InjectWorkspaceRepository(WorkspaceMemberWorkspaceEntity)
private readonly workspaceMemberRepository: WorkspaceRepository<WorkspaceMemberWorkspaceEntity>,
) {}
private async isSyncEmailOnboardingStep(user: User, workspace: Workspace) {
private async isSubscriptionIncompleteOnboardingStatus(user: User) {
const isBillingEnabledForWorkspace =
await this.billingService.isBillingEnabledForWorkspace(
user.defaultWorkspaceId,
);
if (!isBillingEnabledForWorkspace) {
return false;
}
const currentBillingSubscription =
await this.billingService.getCurrentBillingSubscription({
workspaceId: user.defaultWorkspaceId,
});
return (
!isDefined(currentBillingSubscription) ||
currentBillingSubscription?.status === SubscriptionStatus.Incomplete
);
}
private async isWorkspaceActivationOnboardingStatus(user: User) {
return !(await this.workspaceManagerService.doesDataSourceExist(
user.defaultWorkspaceId,
));
}
private async isProfileCreationOnboardingStatus(user: User) {
const workspaceMember = await this.workspaceMemberRepository.findOneBy({
userId: user.id,
});
return (
workspaceMember &&
(!workspaceMember.name.firstName || !workspaceMember.name.lastName)
);
}
private async isSyncEmailOnboardingStatus(user: User) {
const syncEmailValue = await this.keyValuePairService.get({
userId: user.id,
workspaceId: workspace.id,
workspaceId: user.defaultWorkspaceId,
key: OnboardingStepKeys.SYNC_EMAIL_ONBOARDING_STEP,
});
const isSyncEmailSkipped = syncEmailValue === OnboardingStepValues.SKIPPED;
const connectedAccounts =
await this.connectedAccountRepository.getAllByUserId(
user.id,
workspace.id,
user.defaultWorkspaceId,
);
return !isSyncEmailSkipped && !connectedAccounts?.length;
}
private async isInviteTeamOnboardingStep(workspace: Workspace) {
private async isInviteTeamOnboardingStatus(workspace: Workspace) {
const inviteTeamValue = await this.keyValuePairService.get({
workspaceId: workspace.id,
key: OnboardingStepKeys.INVITE_TEAM_ONBOARDING_STEP,
@ -64,19 +113,28 @@ export class OnboardingService {
);
}
async getOnboardingStep(
user: User,
workspace: Workspace,
): Promise<OnboardingStep | null> {
if (await this.isSyncEmailOnboardingStep(user, workspace)) {
return OnboardingStep.SYNC_EMAIL;
async getOnboardingStatus(user: User) {
if (await this.isSubscriptionIncompleteOnboardingStatus(user)) {
return OnboardingStatus.PLAN_REQUIRED;
}
if (await this.isInviteTeamOnboardingStep(workspace)) {
return OnboardingStep.INVITE_TEAM;
if (await this.isWorkspaceActivationOnboardingStatus(user)) {
return OnboardingStatus.WORKSPACE_ACTIVATION;
}
return null;
if (await this.isProfileCreationOnboardingStatus(user)) {
return OnboardingStatus.PROFILE_CREATION;
}
if (await this.isSyncEmailOnboardingStatus(user)) {
return OnboardingStatus.SYNC_EMAIL;
}
if (await this.isInviteTeamOnboardingStatus(user.defaultWorkspace)) {
return OnboardingStatus.INVITE_TEAM;
}
return OnboardingStatus.COMPLETED;
}
async skipInviteTeamOnboardingStep(workspaceId: string) {

View File

@ -107,9 +107,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
return undefined;
}
const workspaceMemberCount = await this.workspaceMemberRepository.count();
return workspaceMemberCount;
return await this.workspaceMemberRepository.count();
}
async checkUserWorkspaceExists(

View File

@ -18,11 +18,11 @@ import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-mem
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { OnboardingStep } from 'src/engine/core-modules/onboarding/enums/onboarding-step.enum';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
registerEnumType(OnboardingStep, {
name: 'OnboardingStep',
description: 'Onboarding step',
registerEnumType(OnboardingStatus, {
name: 'OnboardingStatus',
description: 'Onboarding status',
});
@Entity({ name: 'user', schema: 'core' })
@ -119,6 +119,6 @@ export class User {
@OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.user)
workspaces: Relation<UserWorkspace[]>;
@Field(() => OnboardingStep, { nullable: true })
onboardingStep: OnboardingStep;
@Field(() => OnboardingStatus, { nullable: true })
onboardingStatus: OnboardingStatus;
}

View File

@ -27,7 +27,7 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { OnboardingStep } from 'src/engine/core-modules/onboarding/enums/onboarding-step.enum';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { LoadServiceWithWorkspaceContext } from 'src/engine/twenty-orm/context/load-service-with-workspace.context';
@ -118,17 +118,13 @@ export class UserResolver {
return this.userService.deleteUser(userId);
}
@ResolveField(() => OnboardingStep)
async onboardingStep(@Parent() user: User): Promise<OnboardingStep | null> {
if (!user) {
return null;
}
@ResolveField(() => OnboardingStatus)
async onboardingStatus(@Parent() user: User): Promise<OnboardingStatus> {
const contextInstance = await this.loadServiceWithWorkspaceContext.load(
this.onboardingService,
user.defaultWorkspaceId,
);
return contextInstance.getOnboardingStep(user, user.defaultWorkspace);
return contextInstance.getOnboardingStatus(user);
}
}

View File

@ -10,7 +10,6 @@ import {
Relation,
UpdateDateColumn,
} from 'typeorm';
import Stripe from 'stripe';
import { User } from 'src/engine/core-modules/user/user.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@ -85,10 +84,6 @@ export class Workspace {
@OneToMany(() => FeatureFlagEntity, (featureFlag) => featureFlag.workspace)
featureFlags: Relation<FeatureFlagEntity[]>;
@Field(() => String)
@Column({ type: 'text', default: 'incomplete' })
subscriptionStatus: Stripe.Subscription.Status;
@Field({ nullable: true })
currentBillingSubscription: BillingSubscription;