Fix onboarding status performance issues (#6512)

Updated the onboardingStatus computation to improve performances

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
martmull
2024-08-04 00:33:33 +02:00
committed by GitHub
parent e01d3fd0be
commit 7cd5427589
40 changed files with 757 additions and 767 deletions

View File

@ -1,6 +1,6 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import { IconComponent } from 'twenty-ui';
export type MainButtonVariant = 'primary' | 'secondary';
@ -79,7 +79,7 @@ const StyledButton = styled.button<
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
width: ${({ fullWidth, width }) =>
fullWidth ? '100%' : width ? `${width}px` : 'auto'};
${({ theme, variant }) => {
${({ theme, variant, disabled }) => {
switch (variant) {
case 'secondary':
return `
@ -90,7 +90,11 @@ const StyledButton = styled.button<
default:
return `
&:hover {
background: ${theme.background.primaryInvertedHover}};
background: ${
!disabled
? theme.background.primaryInvertedHover
: theme.background.secondary
};};
}
`;
}

View File

@ -6,7 +6,7 @@ import { Command, CommandRunner, Option } from 'nest-commander';
import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import {
Workspace,
WorkspaceActivationStatus,
@ -32,7 +32,7 @@ export class SetWorkspaceActivationStatusCommand extends CommandRunner {
private readonly typeORMService: TypeORMService,
private readonly dataSourceService: DataSourceService,
private readonly workspaceCacheVersionService: WorkspaceCacheVersionService,
private readonly billingService: BillingService,
private readonly billingSubscriptionService: BillingSubscriptionService,
) {
super();
}
@ -56,7 +56,7 @@ export class SetWorkspaceActivationStatusCommand extends CommandRunner {
activeSubscriptionWorkspaceIds = [options.workspaceId];
} else {
activeSubscriptionWorkspaceIds =
await this.billingService.getActiveSubscriptionWorkspaceIds();
await this.billingSubscriptionService.getActiveSubscriptionWorkspaceIds();
}
if (!activeSubscriptionWorkspaceIds.length) {

View File

@ -32,6 +32,7 @@ import { CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/stan
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { AuthResolver } from './auth.resolver';

View File

@ -9,14 +9,14 @@ import {
import { Response } from 'express';
import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard';
import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { LoadServiceWithWorkspaceContext } from 'src/engine/twenty-orm/context/load-service-with-workspace.context';
import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard';
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { LoadServiceWithWorkspaceContext } from 'src/engine/twenty-orm/context/load-service-with-workspace.context';
@Controller('auth/google-apis')
export class GoogleAPIsAuthController {
@ -93,10 +93,11 @@ export class GoogleAPIsAuthController {
workspaceId,
);
await onboardingServiceInstance.skipSyncEmailOnboardingStep(
await onboardingServiceInstance.toggleOnboardingConnectAccountCompletion({
userId,
workspaceId,
);
value: true,
});
}
return res.redirect(

View File

@ -9,6 +9,7 @@ import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-u
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';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
describe('SignInUpService', () => {
let service: SignInUpService;
@ -21,10 +22,6 @@ describe('SignInUpService', () => {
provide: FileUploadService,
useValue: {},
},
{
provide: UserWorkspaceService,
useValue: {},
},
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {},
@ -34,7 +31,11 @@ describe('SignInUpService', () => {
useValue: {},
},
{
provide: EnvironmentService,
provide: UserWorkspaceService,
useValue: {},
},
{
provide: OnboardingService,
useValue: {},
},
{
@ -42,7 +43,7 @@ describe('SignInUpService', () => {
useValue: {},
},
{
provide: WorkspaceService,
provide: EnvironmentService,
useValue: {},
},
],

View File

@ -18,9 +18,9 @@ import {
hashPassword,
} from 'src/engine/core-modules/auth/auth.util';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import {
Workspace,
WorkspaceActivationStatus,
@ -40,6 +40,7 @@ export type SignInUpServiceInput = {
};
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class SignInUpService {
constructor(
private readonly fileUploadService: FileUploadService,
@ -48,7 +49,7 @@ export class SignInUpService {
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly workspaceService: WorkspaceService,
private readonly onboardingService: OnboardingService,
private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService,
) {}
@ -221,6 +222,14 @@ export class SignInUpService {
await this.userWorkspaceService.create(user.id, workspace.id);
if (user.firstName !== '' || user.lastName === '') {
await this.onboardingService.toggleOnboardingCreateProfileCompletion({
userId: user.id,
workspaceId: workspace.id,
value: true,
});
}
return user;
}

View File

@ -1,19 +1,18 @@
import {
Controller,
Headers,
Req,
RawBodyRequest,
Logger,
Post,
RawBodyRequest,
Req,
Res,
} from '@nestjs/common';
import { Response } from 'express';
import {
BillingWorkspaceService,
WebhookEvent,
} from 'src/engine/core-modules/billing/billing.workspace-service';
import { WebhookEvent } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
@Controller('billing')
@ -22,7 +21,8 @@ export class BillingController {
constructor(
private readonly stripeService: StripeService,
private readonly billingWorkspaceService: BillingWorkspaceService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingWehbookService: BillingWebhookService,
) {}
@Post('/webhooks')
@ -42,7 +42,7 @@ export class BillingController {
);
if (event.type === WebhookEvent.SETUP_INTENT_SUCCEEDED) {
await this.billingWorkspaceService.handleUnpaidInvoices(event.data);
await this.billingSubscriptionService.handleUnpaidInvoices(event.data);
}
if (
@ -58,7 +58,7 @@ export class BillingController {
return;
}
await this.billingWorkspaceService.upsertBillingSubscription(
await this.billingWehbookService.processStripeEvent(
workspaceId,
event.data,
);

View File

@ -2,16 +2,17 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingController } from 'src/engine/core-modules/billing/billing.controller';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { BillingSubscription } 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 { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
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 { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Module({
imports: [
@ -29,11 +30,16 @@ import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing
],
controllers: [BillingController],
providers: [
BillingService,
BillingWorkspaceService,
BillingSubscriptionService,
BillingWebhookService,
BillingPortalWorkspaceService,
BillingResolver,
BillingWorkspaceMemberListener,
],
exports: [BillingService, BillingWorkspaceService],
exports: [
BillingSubscriptionService,
BillingPortalWorkspaceService,
BillingWebhookService,
],
})
export class BillingModule {}

View File

@ -1,43 +1,34 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import {
AvailableProduct,
BillingWorkspaceService,
} from 'src/engine/core-modules/billing/billing.workspace-service';
import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/available-product.interface';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dto/billing-session.input';
import { CheckoutSessionInput } from 'src/engine/core-modules/billing/dto/checkout-session.input';
import { ProductPricesEntity } from 'src/engine/core-modules/billing/dto/product-prices.entity';
import { ProductInput } from 'src/engine/core-modules/billing/dto/product.input';
import { SessionEntity } from 'src/engine/core-modules/billing/dto/session.entity';
import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update-billing.entity';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { assert } from 'src/utils/assert';
@Resolver()
export class BillingResolver {
constructor(
private readonly billingWorkspaceService: BillingWorkspaceService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService,
private readonly stripeService: StripeService,
) {}
@Query(() => ProductPricesEntity)
async getProductPrices(@Args() { product }: ProductInput) {
const stripeProductId =
this.billingWorkspaceService.getProductStripeId(product);
assert(
stripeProductId,
`Product '${product}' not found, available products are ['${Object.values(
AvailableProduct,
).join("','")}']`,
);
const productPrices =
await this.billingWorkspaceService.getProductPrices(stripeProductId);
const productPrices = await this.stripeService.getStripePrices(product);
return {
totalNumberOfPrices: productPrices.length,
@ -52,7 +43,7 @@ export class BillingResolver {
@Args() { returnUrlPath }: BillingSessionInput,
) {
return {
url: await this.billingWorkspaceService.computeBillingPortalSessionURL(
url: await this.billingPortalWorkspaceService.computeBillingPortalSessionURL(
user.defaultWorkspaceId,
returnUrlPath,
),
@ -66,32 +57,22 @@ export class BillingResolver {
@AuthUser() user: User,
@Args() { recurringInterval, successUrlPath }: CheckoutSessionInput,
) {
const stripeProductId = this.billingWorkspaceService.getProductStripeId(
const productPrice = await this.stripeService.getStripePrice(
AvailableProduct.BasePlan,
recurringInterval,
);
assert(
stripeProductId,
'BasePlan productId not found, please check your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID env variable',
);
const productPrices =
await this.billingWorkspaceService.getProductPrices(stripeProductId);
const stripePriceId = productPrices.filter(
(price) => price.recurringInterval === recurringInterval,
)?.[0]?.stripePriceId;
assert(
stripePriceId,
`BasePlan priceId not found, please check body.recurringInterval and product '${AvailableProduct.BasePlan}' prices`,
);
if (!productPrice) {
throw new Error(
'Product price not found for the given recurring interval',
);
}
return {
url: await this.billingWorkspaceService.computeCheckoutSessionURL(
url: await this.billingPortalWorkspaceService.computeCheckoutSessionURL(
user,
workspace,
stripePriceId,
productPrice.stripePriceId,
successUrlPath,
),
};
@ -100,7 +81,7 @@ export class BillingResolver {
@Mutation(() => UpdateBillingEntity)
@UseGuards(JwtAuthGuard)
async updateBillingSubscription(@AuthUser() user: User) {
await this.billingWorkspaceService.updateBillingSubscription(user);
await this.billingSubscriptionService.applyBillingSubscription(user);
return { success: true };
}

View File

@ -1,69 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import {
BillingSubscription,
SubscriptionStatus,
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
@Injectable()
export class BillingService {
protected readonly logger = new Logger(BillingService.name);
constructor(
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
async getActiveSubscriptionWorkspaceIds() {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return (await this.workspaceRepository.find({ select: ['id'] })).map(
(workspace) => workspace.id,
);
}
const activeSubscriptions = await this.billingSubscriptionRepository.find({
where: {
status: In([
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
]),
},
select: ['workspaceId'],
});
const freeAccessFeatureFlags = await this.featureFlagRepository.find({
where: {
key: FeatureFlagKey.IsFreeAccessEnabled,
value: true,
},
select: ['workspaceId'],
});
const activeWorkspaceIdsBasedOnSubscriptions = activeSubscriptions.map(
(subscription) => subscription.workspaceId,
);
const activeWorkspaceIdsBasedOnFeatureFlags = freeAccessFeatureFlags.map(
(featureFlag) => featureFlag.workspaceId,
);
return Array.from(
new Set([
...activeWorkspaceIdsBasedOnSubscriptions,
...activeWorkspaceIdsBasedOnFeatureFlags,
]),
);
}
}

View File

@ -1,354 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Not, Repository } from 'typeorm';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import {
BillingSubscription,
SubscriptionInterval,
SubscriptionStatus,
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { assert } from 'src/utils/assert';
export enum AvailableProduct {
BasePlan = 'base-plan',
}
export enum WebhookEvent {
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
}
@Injectable()
export class BillingWorkspaceService {
protected readonly logger = new Logger(BillingService.name);
constructor(
private readonly stripeService: StripeService,
private readonly userWorkspaceService: UserWorkspaceService,
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 isBillingEnabledForWorkspace(workspaceId: string) {
const isFreeAccessEnabled = await this.featureFlagRepository.findOneBy({
workspaceId,
key: FeatureFlagKey.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');
}
}
async getProductPrices(stripeProductId: string) {
const productPrices =
await this.stripeService.getProductPrices(stripeProductId);
return this.formatProductPrices(productPrices.data);
}
formatProductPrices(prices: Stripe.Price[]) {
const result: Record<string, ProductPriceEntity> = {};
prices.forEach((item) => {
const interval = item.recurring?.interval;
if (!interval || !item.unit_amount) {
return;
}
if (
!result[interval] ||
item.created > (result[interval]?.created || 0)
) {
result[interval] = {
unitAmount: item.unit_amount,
recurringInterval: interval,
created: item.created,
stripePriceId: item.id,
};
}
});
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
}
async getCurrentBillingSubscription(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
}) {
const notCanceledSubscriptions =
await this.billingSubscriptionRepository.find({
where: { ...criteria, status: Not(SubscriptionStatus.Canceled) },
relations: ['billingSubscriptionItems'],
});
assert(
notCanceledSubscriptions.length <= 1,
`More than one not canceled subscription for workspace ${criteria.workspaceId}`,
);
return notCanceledSubscriptions?.[0];
}
async getBillingSubscription(stripeSubscriptionId: string) {
return this.billingSubscriptionRepository.findOneOrFail({
where: { stripeSubscriptionId },
});
}
async getStripeCustomerId(workspaceId: string) {
const subscriptions = await this.billingSubscriptionRepository.find({
where: { workspaceId },
});
return subscriptions?.[0]?.stripeCustomerId;
}
async getBillingSubscriptionItem(
workspaceId: string,
stripeProductId = this.environmentService.get(
'BILLING_STRIPE_BASE_PLAN_PRODUCT_ID',
),
) {
const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId,
});
if (!billingSubscription) {
throw new Error(
`Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`,
);
}
const billingSubscriptionItem =
billingSubscription.billingSubscriptionItems.filter(
(billingSubscriptionItem) =>
billingSubscriptionItem.stripeProductId === stripeProductId,
)?.[0];
if (!billingSubscriptionItem) {
throw new Error(
`Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`,
);
}
return billingSubscriptionItem;
}
async computeBillingPortalSessionURL(
workspaceId: string,
returnUrlPath?: string,
) {
const stripeCustomerId = await this.getStripeCustomerId(workspaceId);
if (!stripeCustomerId) {
return;
}
const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL');
const returnUrl = returnUrlPath
? frontBaseUrl + returnUrlPath
: frontBaseUrl;
const session = await this.stripeService.createBillingPortalSession(
stripeCustomerId,
returnUrl,
);
assert(session.url, 'Error: missing billingPortal.session.url');
return session.url;
}
async updateBillingSubscription(user: User) {
const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId: user.defaultWorkspaceId,
});
const newInterval =
billingSubscription?.interval === SubscriptionInterval.Year
? SubscriptionInterval.Month
: SubscriptionInterval.Year;
const billingSubscriptionItem = await this.getBillingSubscriptionItem(
user.defaultWorkspaceId,
);
const stripeProductId = this.getProductStripeId(AvailableProduct.BasePlan);
if (!stripeProductId) {
throw new Error('Stripe product id not found for basePlan');
}
const productPrices = await this.getProductPrices(stripeProductId);
const stripePriceId = productPrices.filter(
(price) => price.recurringInterval === newInterval,
)?.[0]?.stripePriceId;
await this.stripeService.updateBillingSubscriptionItem(
billingSubscriptionItem,
stripePriceId,
);
}
async computeCheckoutSessionURL(
user: User,
workspace: Workspace,
priceId: string,
successUrlPath?: string,
): Promise<string> {
const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL');
const successUrl = successUrlPath
? frontBaseUrl + successUrlPath
: frontBaseUrl;
const quantity =
(await this.userWorkspaceService.getUserCount(workspace.id)) || 1;
const stripeCustomerId = (
await this.billingSubscriptionRepository.findOneBy({
workspaceId: user.defaultWorkspaceId,
})
)?.stripeCustomerId;
const session = await this.stripeService.createCheckoutSession(
user,
priceId,
quantity,
successUrl,
frontBaseUrl,
stripeCustomerId,
);
assert(session.url, 'Error: missing checkout.session.url');
return session.url;
}
async deleteSubscription(workspaceId: string) {
const subscriptionToCancel = await this.getCurrentBillingSubscription({
workspaceId,
});
if (subscriptionToCancel) {
await this.stripeService.cancelSubscription(
subscriptionToCancel.stripeSubscriptionId,
);
await this.billingSubscriptionRepository.delete(subscriptionToCancel.id);
}
}
async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) {
const billingSubscription = await this.getCurrentBillingSubscription({
stripeCustomerId: data.object.customer as string,
});
if (billingSubscription?.status === 'unpaid') {
await this.stripeService.collectLastInvoice(
billingSubscription.stripeSubscriptionId,
);
}
}
async upsertBillingSubscription(
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
if (!workspace) {
return;
}
await this.billingSubscriptionRepository.upsert(
{
workspaceId: workspaceId,
stripeCustomerId: data.object.customer as string,
stripeSubscriptionId: data.object.id,
status: data.object.status,
interval: data.object.items.data[0].plan.interval,
},
{
conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
},
);
const billingSubscription = await this.getBillingSubscription(
data.object.id,
);
await this.billingSubscriptionItemRepository.upsert(
data.object.items.data.map((item) => {
return {
billingSubscriptionId: billingSubscription.id,
stripeProductId: item.price.product as string,
stripePriceId: item.price.id,
stripeSubscriptionItemId: item.id,
quantity: item.quantity,
};
}),
{
conflictPaths: ['billingSubscriptionId', 'stripeProductId'],
skipUpdateIfNoValuesChanged: true,
},
);
await this.featureFlagRepository.delete({
workspaceId,
key: FeatureFlagKey.IsFreeAccessEnabled,
});
if (
data.object.status === SubscriptionStatus.Canceled ||
data.object.status === SubscriptionStatus.Unpaid
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.INACTIVE,
});
}
if (
(data.object.status === SubscriptionStatus.Active ||
data.object.status === SubscriptionStatus.Trialing ||
data.object.status === SubscriptionStatus.PastDue) &&
workspace.activationStatus == WorkspaceActivationStatus.INACTIVE
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
}
}
}

View File

@ -2,7 +2,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
import { AvailableProduct } from 'src/engine/core-modules/billing/billing.workspace-service';
import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/available-product.interface';
@ArgsType()
export class ProductInput {

View File

@ -0,0 +1,3 @@
export enum AvailableProduct {
BasePlan = 'base-plan',
}

View File

@ -1,6 +1,6 @@
import { Logger, Scope } from '@nestjs/common';
import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
@ -16,7 +16,7 @@ export class UpdateSubscriptionJob {
protected readonly logger = new Logger(UpdateSubscriptionJob.name);
constructor(
private readonly billingWorkspaceService: BillingWorkspaceService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly stripeService: StripeService,
) {}
@ -33,7 +33,7 @@ export class UpdateSubscriptionJob {
try {
const billingSubscriptionItem =
await this.billingWorkspaceService.getBillingSubscriptionItem(
await this.billingSubscriptionService.getCurrentBillingSubscriptionItem(
data.workspaceId,
);

View File

@ -1,23 +1,23 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import {
UpdateSubscriptionJob,
UpdateSubscriptionJobData,
} from 'src/engine/core-modules/billing/jobs/update-subscription.job';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class BillingWorkspaceMemberListener {
constructor(
@InjectMessageQueue(MessageQueue.billingQueue)
private readonly messageQueueService: MessageQueueService,
private readonly billingWorkspaceService: BillingWorkspaceService,
private readonly environmentService: EnvironmentService,
) {}
@OnEvent('workspaceMember.created')
@ -25,12 +25,7 @@ export class BillingWorkspaceMemberListener {
async handleCreateOrDeleteEvent(
payload: ObjectRecordCreateEvent<WorkspaceMemberWorkspaceEntity>,
) {
const isBillingEnabledForWorkspace =
await this.billingWorkspaceService.isBillingEnabledForWorkspace(
payload.workspaceId,
);
if (!isBillingEnabledForWorkspace) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}

View File

@ -0,0 +1,93 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { assert } from 'src/utils/assert';
export enum WebhookEvent {
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
}
@Injectable()
export class BillingPortalWorkspaceService {
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
constructor(
private readonly stripeService: StripeService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
private readonly billingSubscriptionService: BillingSubscriptionService,
) {}
async computeCheckoutSessionURL(
user: User,
workspace: Workspace,
priceId: string,
successUrlPath?: string,
): Promise<string> {
const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL');
const successUrl = successUrlPath
? frontBaseUrl + successUrlPath
: frontBaseUrl;
const quantity =
(await this.userWorkspaceService.getUserCount(workspace.id)) || 1;
const stripeCustomerId = (
await this.billingSubscriptionRepository.findOneBy({
workspaceId: user.defaultWorkspaceId,
})
)?.stripeCustomerId;
const session = await this.stripeService.createCheckoutSession(
user,
priceId,
quantity,
successUrl,
frontBaseUrl,
stripeCustomerId,
);
assert(session.url, 'Error: missing checkout.session.url');
return session.url;
}
async computeBillingPortalSessionURL(
workspaceId: string,
returnUrlPath?: string,
) {
const currentSubscriptionItem =
await this.billingSubscriptionService.getCurrentBillingSubscription({
workspaceId,
});
const stripeCustomerId = currentSubscriptionItem.stripeCustomerId;
const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL');
const returnUrl = returnUrlPath
? frontBaseUrl + returnUrlPath
: frontBaseUrl;
const session = await this.stripeService.createBillingPortalSession(
stripeCustomerId,
returnUrl,
);
assert(session.url, 'Error: missing billingPortal.session.url');
return session.url;
}
}

View File

@ -0,0 +1,185 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
import { User } from '@sentry/node';
import Stripe from 'stripe';
import { In, Not, Repository } from 'typeorm';
import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/available-product.interface';
import {
BillingSubscription,
SubscriptionInterval,
SubscriptionStatus,
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
@Injectable()
export class BillingSubscriptionService {
protected readonly logger = new Logger(BillingSubscriptionService.name);
constructor(
private readonly stripeService: StripeService,
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
/**
* @deprecated This is fully deprecated, it's only used in the migration script for 0.23
*/
async getActiveSubscriptionWorkspaceIds() {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return (await this.workspaceRepository.find({ select: ['id'] })).map(
(workspace) => workspace.id,
);
}
const activeSubscriptions = await this.billingSubscriptionRepository.find({
where: {
status: In([
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
]),
},
select: ['workspaceId'],
});
const freeAccessFeatureFlags = await this.featureFlagRepository.find({
where: {
key: FeatureFlagKey.IsFreeAccessEnabled,
value: true,
},
select: ['workspaceId'],
});
const activeWorkspaceIdsBasedOnSubscriptions = activeSubscriptions.map(
(subscription) => subscription.workspaceId,
);
const activeWorkspaceIdsBasedOnFeatureFlags = freeAccessFeatureFlags.map(
(featureFlag) => featureFlag.workspaceId,
);
return Array.from(
new Set([
...activeWorkspaceIdsBasedOnSubscriptions,
...activeWorkspaceIdsBasedOnFeatureFlags,
]),
);
}
async getCurrentBillingSubscription(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
}) {
const notCanceledSubscriptions =
await this.billingSubscriptionRepository.find({
where: { ...criteria, status: Not(SubscriptionStatus.Canceled) },
relations: ['billingSubscriptionItems'],
});
assert(
notCanceledSubscriptions.length <= 1,
`More than one not canceled subscription for workspace ${criteria.workspaceId}`,
);
return notCanceledSubscriptions?.[0];
}
async getCurrentBillingSubscriptionItem(
workspaceId: string,
stripeProductId = this.environmentService.get(
'BILLING_STRIPE_BASE_PLAN_PRODUCT_ID',
),
) {
const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId,
});
if (!billingSubscription) {
throw new Error(
`Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`,
);
}
const billingSubscriptionItem =
billingSubscription.billingSubscriptionItems.filter(
(billingSubscriptionItem) =>
billingSubscriptionItem.stripeProductId === stripeProductId,
)?.[0];
if (!billingSubscriptionItem) {
throw new Error(
`Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`,
);
}
return billingSubscriptionItem;
}
async deleteSubscription(workspaceId: string) {
const subscriptionToCancel = await this.getCurrentBillingSubscription({
workspaceId,
});
if (subscriptionToCancel) {
await this.stripeService.cancelSubscription(
subscriptionToCancel.stripeSubscriptionId,
);
await this.billingSubscriptionRepository.delete(subscriptionToCancel.id);
}
}
async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) {
const billingSubscription = await this.getCurrentBillingSubscription({
stripeCustomerId: data.object.customer as string,
});
if (billingSubscription?.status === 'unpaid') {
await this.stripeService.collectLastInvoice(
billingSubscription.stripeSubscriptionId,
);
}
}
async applyBillingSubscription(user: User) {
const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId: user.defaultWorkspaceId,
});
const newInterval =
billingSubscription?.interval === SubscriptionInterval.Year
? SubscriptionInterval.Month
: SubscriptionInterval.Year;
const billingSubscriptionItem =
await this.getCurrentBillingSubscriptionItem(user.defaultWorkspaceId);
const productPrice = await this.stripeService.getStripePrice(
AvailableProduct.BasePlan,
newInterval,
);
if (!productPrice) {
throw new Error(
`Cannot find product price for product ${AvailableProduct.BasePlan} and interval ${newInterval}`,
);
}
await this.stripeService.updateBillingSubscriptionItem(
billingSubscriptionItem,
productPrice.stripePriceId,
);
}
}

View File

@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Repository } from 'typeorm';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import {
BillingSubscription,
SubscriptionStatus,
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class BillingWebhookService {
protected readonly logger = new Logger(BillingWebhookService.name);
constructor(
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
async processStripeEvent(
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
if (!workspace) {
return;
}
await this.billingSubscriptionRepository.upsert(
{
workspaceId: workspaceId,
stripeCustomerId: data.object.customer as string,
stripeSubscriptionId: data.object.id,
status: data.object.status,
interval: data.object.items.data[0].plan.interval,
},
{
conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
},
);
const billingSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { stripeSubscriptionId: data.object.id },
});
await this.billingSubscriptionItemRepository.upsert(
data.object.items.data.map((item) => {
return {
billingSubscriptionId: billingSubscription.id,
stripeProductId: item.price.product as string,
stripePriceId: item.price.id,
stripeSubscriptionItemId: item.id,
quantity: item.quantity,
};
}),
{
conflictPaths: ['billingSubscriptionId', 'stripeProductId'],
skipUpdateIfNoValuesChanged: true,
},
);
if (
data.object.status === SubscriptionStatus.Canceled ||
data.object.status === SubscriptionStatus.Unpaid
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.INACTIVE,
});
}
if (
(data.object.status === SubscriptionStatus.Active ||
data.object.status === SubscriptionStatus.Trialing ||
data.object.status === SubscriptionStatus.PastDue) &&
workspace.activationStatus == WorkspaceActivationStatus.INACTIVE
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
}
}
}

View File

@ -2,9 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/available-product.interface';
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
@Injectable()
export class StripeService {
@ -30,10 +33,28 @@ export class StripeService {
);
}
async getProductPrices(stripeProductId: string) {
return this.stripe.prices.search({
async getStripePrices(product: AvailableProduct) {
const stripeProductId = this.getStripeProductId(product);
const prices = await this.stripe.prices.search({
query: `product: '${stripeProductId}'`,
});
return this.formatProductPrices(prices.data);
}
async getStripePrice(product: AvailableProduct, recurringInterval: string) {
const productPrices = await this.getStripePrices(product);
return productPrices.find(
(price) => price.recurringInterval === recurringInterval,
);
}
getStripeProductId(product: AvailableProduct) {
if (product === AvailableProduct.BasePlan) {
return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID');
}
}
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
@ -119,4 +140,29 @@ export class StripeService {
},
);
}
formatProductPrices(prices: Stripe.Price[]) {
const result: Record<string, ProductPriceEntity> = {};
prices.forEach((item) => {
const interval = item.recurring?.interval;
if (!interval || !item.unit_amount) {
return;
}
if (
!result[interval] ||
item.created > (result[interval]?.created || 0)
) {
result[interval] = {
unitAmount: item.unit_amount,
recurringInterval: interval,
created: item.created,
stripePriceId: item.id,
};
}
});
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
}
}

View File

@ -3,21 +3,10 @@ import { Module } from '@nestjs/common';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { OnboardingResolver } from 'src/engine/core-modules/onboarding/onboarding.resolver';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { EnvironmentModule } from 'src/engine/integrations/environment/environment.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
@Module({
imports: [
DataSourceModule,
WorkspaceManagerModule,
UserWorkspaceModule,
EnvironmentModule,
BillingModule,
UserVarsModule,
],
imports: [BillingModule, UserVarsModule],
exports: [OnboardingService],
providers: [OnboardingService, OnboardingResolver],
})

View File

@ -1,13 +1,13 @@
import { UseGuards } from '@nestjs/common';
import { Mutation, Resolver } from '@nestjs/graphql';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { OnboardingStepSuccess } from 'src/engine/core-modules/onboarding/dtos/onboarding-step-success.dto';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
@UseGuards(JwtAuthGuard)
@Resolver()
@ -19,10 +19,11 @@ export class OnboardingResolver {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<OnboardingStepSuccess> {
await this.onboardingService.skipSyncEmailOnboardingStep(
user.id,
workspace.id,
);
await this.onboardingService.toggleOnboardingConnectAccountCompletion({
userId: user.id,
workspaceId: workspace.id,
value: true,
});
return { success: true };
}

View File

@ -1,59 +1,43 @@
/* eslint-disable @nx/workspace-inject-workspace-repository */
import { Injectable } from '@nestjs/common';
import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { WorkspaceActivationStatus } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { isDefined } from 'src/utils/is-defined';
enum OnboardingStepValues {
SKIPPED = 'SKIPPED',
export enum OnboardingStepKeys {
ONBOARDING_CONNECT_ACCOUNT_COMPLETE = 'ONBOARDING_CONNECT_ACCOUNT_COMPLETE',
ONBOARDING_INVITE_TEAM_COMPLETE = 'ONBOARDING_INVITE_TEAM_COMPLETE',
ONBOARDING_CREATE_PROFILE_COMPLETE = 'ONBOARDING_CREATE_PROFILE_COMPLETE',
}
enum OnboardingStepKeys {
SYNC_EMAIL_ONBOARDING_STEP = 'SYNC_EMAIL_ONBOARDING_STEP',
INVITE_TEAM_ONBOARDING_STEP = 'INVITE_TEAM_ONBOARDING_STEP',
}
type OnboardingKeyValueTypeMap = {
[OnboardingStepKeys.SYNC_EMAIL_ONBOARDING_STEP]: OnboardingStepValues;
[OnboardingStepKeys.INVITE_TEAM_ONBOARDING_STEP]: OnboardingStepValues;
export type OnboardingKeyValueTypeMap = {
[OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_COMPLETE]: boolean;
[OnboardingStepKeys.ONBOARDING_INVITE_TEAM_COMPLETE]: boolean;
[OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_COMPLETE]: boolean;
};
@Injectable()
export class OnboardingService {
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly billingWorkspaceService: BillingWorkspaceService,
private readonly workspaceManagerService: WorkspaceManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly environmentService: EnvironmentService,
private readonly userVarsService: UserVarsService<OnboardingKeyValueTypeMap>,
@InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity)
private readonly connectedAccountRepository: ConnectedAccountRepository,
) {}
private async isSubscriptionIncompleteOnboardingStatus(user: User) {
const isBillingEnabledForWorkspace =
await this.billingWorkspaceService.isBillingEnabledForWorkspace(
user.defaultWorkspaceId,
);
const isBillingEnabled = this.environmentService.get('IS_BILLING_ENABLED');
if (!isBillingEnabledForWorkspace) {
if (!isBillingEnabled) {
return false;
}
const currentBillingSubscription =
await this.billingWorkspaceService.getCurrentBillingSubscription({
await this.billingSubscriptionService.getCurrentBillingSubscription({
workspaceId: user.defaultWorkspaceId,
});
@ -64,57 +48,9 @@ export class OnboardingService {
}
private async isWorkspaceActivationOnboardingStatus(user: User) {
return !(await this.workspaceManagerService.doesDataSourceExist(
user.defaultWorkspaceId,
));
}
private async isProfileCreationOnboardingStatus(user: User) {
const workspaceMemberRepository =
await this.twentyORMManager.getRepository<WorkspaceMemberWorkspaceEntity>(
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOneBy({
userId: user.id,
});
return (
workspaceMember &&
(!workspaceMember.name.firstName || !workspaceMember.name.lastName)
);
}
private async isSyncEmailOnboardingStatus(user: User) {
const syncEmailValue = await this.userVarsService.get({
userId: user.id,
workspaceId: user.defaultWorkspaceId,
key: OnboardingStepKeys.SYNC_EMAIL_ONBOARDING_STEP,
});
const isSyncEmailSkipped = syncEmailValue === OnboardingStepValues.SKIPPED;
const connectedAccounts =
await this.connectedAccountRepository.getAllByUserId(
user.id,
user.defaultWorkspaceId,
);
return !isSyncEmailSkipped && !connectedAccounts?.length;
}
private async isInviteTeamOnboardingStatus(workspace: Workspace) {
const inviteTeamValue = await this.userVarsService.get({
workspaceId: workspace.id,
key: OnboardingStepKeys.INVITE_TEAM_ONBOARDING_STEP,
});
const isInviteTeamSkipped =
inviteTeamValue === OnboardingStepValues.SKIPPED;
const workspaceMemberCount = await this.userWorkspaceService.getUserCount(
workspace.id,
);
return (
!isInviteTeamSkipped &&
(!workspaceMemberCount || workspaceMemberCount <= 1)
user.defaultWorkspace.activationStatus ===
WorkspaceActivationStatus.PENDING_CREATION
);
}
@ -127,35 +63,82 @@ export class OnboardingService {
return OnboardingStatus.WORKSPACE_ACTIVATION;
}
if (await this.isProfileCreationOnboardingStatus(user)) {
const userVars = await this.userVarsService.getAll({
userId: user.id,
workspaceId: user.defaultWorkspaceId,
});
const isProfileCreationComplete =
userVars.get(OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_COMPLETE) ===
true;
const isConnectAccountComplete =
userVars.get(OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_COMPLETE) ===
true;
const isInviteTeamComplete =
userVars.get(OnboardingStepKeys.ONBOARDING_INVITE_TEAM_COMPLETE) === true;
if (!isProfileCreationComplete) {
return OnboardingStatus.PROFILE_CREATION;
}
if (await this.isSyncEmailOnboardingStatus(user)) {
if (!isConnectAccountComplete) {
return OnboardingStatus.SYNC_EMAIL;
}
if (await this.isInviteTeamOnboardingStatus(user.defaultWorkspace)) {
if (!isInviteTeamComplete) {
return OnboardingStatus.INVITE_TEAM;
}
return OnboardingStatus.COMPLETED;
}
async skipInviteTeamOnboardingStep(workspaceId: string) {
async toggleOnboardingConnectAccountCompletion({
userId,
workspaceId,
value,
}: {
userId: string;
workspaceId: string;
value: boolean;
}) {
await this.userVarsService.set({
workspaceId,
key: OnboardingStepKeys.INVITE_TEAM_ONBOARDING_STEP,
value: OnboardingStepValues.SKIPPED,
userId,
workspaceId: workspaceId,
key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_COMPLETE,
value,
});
}
async skipSyncEmailOnboardingStep(userId: string, workspaceId: string) {
async toggleOnboardingInviteTeamCompletion({
workspaceId,
value,
}: {
workspaceId: string;
value: boolean;
}) {
await this.userVarsService.set({
workspaceId,
key: OnboardingStepKeys.ONBOARDING_INVITE_TEAM_COMPLETE,
value,
});
}
async toggleOnboardingCreateProfileCompletion({
userId,
workspaceId,
value,
}: {
userId: string;
workspaceId: string;
value: boolean;
}) {
await this.userVarsService.set({
userId,
workspaceId,
key: OnboardingStepKeys.SYNC_EMAIL_ONBOARDING_STEP,
value: OnboardingStepValues.SKIPPED,
key: OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_COMPLETE,
value,
});
}
}

View File

@ -37,6 +37,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
payload.workspaceId = workspaceId;
payload.userId = userId;
payload.name = 'user.signup';
this.eventEmitter.emit('user.signup', payload);
@ -80,6 +81,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
after: workspaceMember[0],
};
payload.recordId = workspaceMember[0].id;
payload.name = 'workspaceMember.created';
this.eventEmitter.emit('workspaceMember.created', payload);
}

View File

@ -18,6 +18,7 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class UserService extends TypeOrmQueryService<User> {
constructor(
@InjectRepository(User, 'core')
@ -115,6 +116,7 @@ export class UserService extends TypeOrmQueryService<User> {
};
payload.name = 'workspaceMember.deleted';
payload.recordId = workspaceMember.id;
payload.name = 'workspaceMember.deleted';
this.eventEmitter.emit('workspaceMember.deleted', payload);

View File

@ -1,16 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { WorkspaceService } from './workspace.service';
@ -46,7 +46,7 @@ describe('WorkspaceService', () => {
useValue: {},
},
{
provide: BillingWorkspaceService,
provide: BillingSubscriptionService,
useValue: {},
},
{

View File

@ -8,7 +8,7 @@ import { render } from '@react-email/render';
import { SendInviteLinkEmail } from 'twenty-emails';
import { Repository } from 'typeorm';
import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@ -23,6 +23,7 @@ import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
constructor(
@InjectRepository(Workspace, 'core')
@ -33,7 +34,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly workspaceManagerService: WorkspaceManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly billingWorkspaceService: BillingWorkspaceService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
private readonly onboardingService: OnboardingService,
@ -65,7 +66,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
assert(workspace, 'Workspace not found');
await this.userWorkspaceRepository.delete({ workspaceId: id });
await this.billingWorkspaceService.deleteSubscription(workspace.id);
await this.billingSubscriptionService.deleteSubscription(workspace.id);
await this.workspaceManagerService.delete(id);
@ -99,7 +100,6 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
userId,
workspaceId,
});
await this.onboardingService.skipInviteTeamOnboardingStep(workspaceId);
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
}
@ -142,7 +142,10 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
});
}
await this.onboardingService.skipInviteTeamOnboardingStep(workspace.id);
await this.onboardingService.toggleOnboardingInviteTeamCompletion({
workspaceId: workspace.id,
value: true,
});
return { success: true };
}

View File

@ -1,23 +1,51 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { ObjectRecordDeleteEvent } from 'src/engine/integrations/event-emitter/types/object-record-delete.event';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import {
HandleWorkspaceMemberDeletedJob,
HandleWorkspaceMemberDeletedJobData,
} from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
import { ObjectRecordDeleteEvent } from 'src/engine/integrations/event-emitter/types/object-record-delete.event';
import { ObjectRecordUpdateEvent } from 'src/engine/integrations/event-emitter/types/object-record-update.event';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class WorkspaceWorkspaceMemberListener {
constructor(
private readonly onboardingService: OnboardingService,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnEvent('workspaceMember.updated')
async handleUpdateEvent(
payload: ObjectRecordUpdateEvent<WorkspaceMemberWorkspaceEntity>,
) {
const { firstName: firstNameBefore, lastName: lastNameBefore } =
payload.properties.before.name;
const { firstName: firstNameAfter, lastName: lastNameAfter } =
payload.properties.after.name;
if (firstNameAfter === '' && lastNameAfter === '') {
return;
}
if (!payload.userId) {
return;
}
await this.onboardingService.toggleOnboardingCreateProfileCompletion({
userId: payload.userId,
workspaceId: payload.workspaceId,
value: true,
});
}
@OnEvent('workspaceMember.deleted')
async handleDeleteEvent(
payload: ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>,

View File

@ -17,6 +17,7 @@ import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.r
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceCacheVersionModule } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
import { Workspace } from './workspace.entity';

View File

@ -12,8 +12,8 @@ import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { BillingWorkspaceService } from 'src/engine/core-modules/billing/billing.workspace-service';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.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 { User } from 'src/engine/core-modules/user/user.entity';
@ -41,7 +41,7 @@ export class WorkspaceResolver {
private readonly workspaceCacheVersionService: WorkspaceCacheVersionService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly fileUploadService: FileUploadService,
private readonly billingWorkspaceService: BillingWorkspaceService,
private readonly billingSubscriptionService: BillingSubscriptionService,
) {}
@Query(() => Workspace)
@ -112,7 +112,7 @@ export class WorkspaceResolver {
async currentBillingSubscription(
@Parent() workspace: Workspace,
): Promise<BillingSubscription | null> {
return this.billingWorkspaceService.getCurrentBillingSubscription({
return this.billingSubscriptionService.getCurrentBillingSubscription({
workspaceId: workspace.id,
});
}

View File

@ -15,10 +15,10 @@ export const companyPrefillDemoData = async (
'addressAddressCity',
'employees',
'linkedinLinkPrimaryLinkUrl',
'position',
'createdBySource',
'createdByWorkspaceMemberId',
'createdByName'
'createdByName',
'position'
])
.orIgnore()
.values(

View File

@ -1,3 +1,4 @@
import { DEMO_SEED_WORKSPACE_MEMBER_IDS } from 'src/engine/workspace-manager/demo-objects-prefill-data/workspace-member';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
@ -25,6 +26,9 @@ const generateOpportunities = (companies) => {
stage: getRandomStage(),
pointOfContactId: company.personId,
companyId: company.id,
createdBySource: 'MANUAL',
createdByWorkspaceMemberId: DEMO_SEED_WORKSPACE_MEMBER_IDS.NOAH,
createdByName: 'Noah A',
}));
};
@ -53,6 +57,9 @@ export const opportunityPrefillDemoData = async (
'stage',
'pointOfContactId',
'companyId',
'createdBySource',
'createdByWorkspaceMemberId',
'createdByName',
'position',
])
.orIgnore()

View File

@ -19,11 +19,11 @@ export const personPrefillDemoData = async (
jobTitle: person.jobTitle,
city: person.city,
avatarUrl: person.avatarUrl,
position: index,
companyId: companies[Math.floor(index / 2)].id,
createdBySource: person.createdBySource,
createdByWorkspaceMemberId: person.createdByWorkspaceMemberId,
createdByName: person.createdByName
createdByName: person.createdByName,
position: index
}));
await entityManager
@ -37,11 +37,11 @@ export const personPrefillDemoData = async (
'jobTitle',
'city',
'avatarUrl',
'position',
'companyId',
'createdBySource',
'createdByWorkspaceMemberId',
'createdByName',
'position',
])
.orIgnore()
.values(people)

View File

@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
@ -44,7 +45,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
WorkspaceMemberWorkspaceEntity,
]),
CalendarEventParticipantManagerModule,
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
TypeOrmModule.forFeature([FeatureFlagEntity, Workspace], 'core'),
TypeOrmModule.forFeature([DataSourceEntity], 'metadata'),
WorkspaceDataSourceModule,
CalendarEventCleanerModule,

View File

@ -1,14 +1,16 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Any, In, Repository } from 'typeorm';
import { Any, Repository } from 'typeorm';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
CalendarEventListFetchJob,
@ -21,33 +23,25 @@ import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-o
})
export class CalendarEventListFetchCronJob {
constructor(
@InjectRepository(DataSourceEntity, 'metadata')
private readonly dataSourceRepository: Repository<DataSourceEntity>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly messageQueueService: MessageQueueService,
private readonly billingService: BillingService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
@Process(CalendarEventListFetchCronJob.name)
async handle(): Promise<void> {
const workspaceIds =
await this.billingService.getActiveSubscriptionWorkspaceIds();
const dataSources = await this.dataSourceRepository.find({
const activeWorkspaces = await this.workspaceRepository.find({
where: {
workspaceId: In(workspaceIds),
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
const workspaceIdsWithDataSources = new Set(
dataSources.map((dataSource) => dataSource.workspaceId),
);
for (const workspaceId of workspaceIdsWithDataSources) {
for (const activeWorkspace of activeWorkspaces) {
const calendarChannelRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
activeWorkspace.id,
'calendarChannel',
);
@ -66,7 +60,7 @@ export class CalendarEventListFetchCronJob {
CalendarEventListFetchJob.name,
{
calendarChannelId: calendarChannel.id,
workspaceId,
workspaceId: activeWorkspace.id,
},
);
}

View File

@ -141,6 +141,7 @@ export class CalendarSaveEventsService {
this.eventEmitter.emit(`calendarEventParticipant.matched`, {
workspaceId,
name: 'calendarEventParticipant.matched',
workspaceMemberId: connectedAccount.accountOwnerId,
calendarEventParticipants: savedCalendarEventParticipantsToEmit,
});

View File

@ -157,6 +157,7 @@ export class MatchParticipantService<
this.eventEmitter.emit(`${objectMetadataName}.matched`, {
workspaceId,
name: `${objectMetadataName}.matched`,
workspaceMemberId: null,
participants: updatedParticipants,
});

View File

@ -1,9 +1,15 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Repository } from 'typeorm';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
@ -13,46 +19,35 @@ import {
MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessagingMessageListFetchJobData,
MessagingMessageListFetchJob,
MessagingMessageListFetchJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
@Processor(MessageQueue.cronQueue)
export class MessagingMessageListFetchCronJob {
private readonly logger = new Logger(MessagingMessageListFetchCronJob.name);
constructor(
@InjectRepository(DataSourceEntity, 'metadata')
private readonly dataSourceRepository: Repository<DataSourceEntity>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectMessageQueue(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService,
@InjectObjectMetadataRepository(MessageChannelWorkspaceEntity)
private readonly messageChannelRepository: MessageChannelRepository,
private readonly billingService: BillingService,
) {}
@Process(MessagingMessageListFetchCronJob.name)
async handle(): Promise<void> {
const workspaceIds =
await this.billingService.getActiveSubscriptionWorkspaceIds();
const dataSources = await this.dataSourceRepository.find({
const activeWorkspaces = await this.workspaceRepository.find({
where: {
workspaceId: In(workspaceIds),
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
const workspaceIdsWithDataSources = new Set(
dataSources.map((dataSource) => dataSource.workspaceId),
);
for (const workspaceId of workspaceIdsWithDataSources) {
const messageChannels =
await this.messageChannelRepository.getAll(workspaceId);
for (const activeWorkspace of activeWorkspaces) {
const messageChannels = await this.messageChannelRepository.getAll(
activeWorkspace.id,
);
for (const messageChannel of messageChannels) {
if (
@ -65,7 +60,7 @@ export class MessagingMessageListFetchCronJob {
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
{
workspaceId,
workspaceId: activeWorkspace.id,
messageChannelId: messageChannel.id,
},
);

View File

@ -1,58 +1,53 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Repository } from 'typeorm';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import {
MessagingMessagesImportJobData,
MessagingMessagesImportJob,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-messages-import.job';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { MessageChannelRepository } from 'src/modules/messaging/common/repositories/message-channel.repository';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { MessageChannelRepository } from 'src/modules/messaging/common/repositories/message-channel.repository';
import {
MessageChannelSyncStage,
MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
import {
MessagingMessagesImportJob,
MessagingMessagesImportJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-messages-import.job';
@Processor(MessageQueue.cronQueue)
export class MessagingMessagesImportCronJob {
private readonly logger = new Logger(MessagingMessagesImportCronJob.name);
constructor(
@InjectRepository(DataSourceEntity, 'metadata')
private readonly dataSourceRepository: Repository<DataSourceEntity>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectMessageQueue(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService,
@InjectObjectMetadataRepository(MessageChannelWorkspaceEntity)
private readonly messageChannelRepository: MessageChannelRepository,
private readonly billingService: BillingService,
) {}
@Process(MessagingMessagesImportCronJob.name)
async handle(): Promise<void> {
const workspaceIds =
await this.billingService.getActiveSubscriptionWorkspaceIds();
const dataSources = await this.dataSourceRepository.find({
const activeWorkspaces = await this.workspaceRepository.find({
where: {
workspaceId: In(workspaceIds),
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
const workspaceIdsWithDataSources = new Set(
dataSources.map((dataSource) => dataSource.workspaceId),
);
for (const workspaceId of workspaceIdsWithDataSources) {
const messageChannels =
await this.messageChannelRepository.getAll(workspaceId);
for (const activeWorkspace of activeWorkspaces) {
const messageChannels = await this.messageChannelRepository.getAll(
activeWorkspace.id,
);
for (const messageChannel of messageChannels) {
if (
@ -63,7 +58,7 @@ export class MessagingMessagesImportCronJob {
await this.messageQueueService.add<MessagingMessagesImportJobData>(
MessagingMessagesImportJob.name,
{
workspaceId,
workspaceId: activeWorkspace.id,
messageChannelId: messageChannel.id,
},
);

View File

@ -1,49 +1,43 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Repository } from 'typeorm';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import {
MessagingOngoingStaleJobData,
MessagingOngoingStaleJob,
MessagingOngoingStaleJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-ongoing-stale.job';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
@Processor(MessageQueue.cronQueue)
export class MessagingOngoingStaleCronJob {
constructor(
@InjectRepository(DataSourceEntity, 'metadata')
private readonly dataSourceRepository: Repository<DataSourceEntity>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectMessageQueue(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService,
private readonly billingService: BillingService,
) {}
@Process(MessagingOngoingStaleCronJob.name)
async handle(): Promise<void> {
const workspaceIds =
await this.billingService.getActiveSubscriptionWorkspaceIds();
const dataSources = await this.dataSourceRepository.find({
const activeWorkspaces = await this.workspaceRepository.find({
where: {
workspaceId: In(workspaceIds),
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
const workspaceIdsWithDataSources = new Set(
dataSources.map((dataSource) => dataSource.workspaceId),
);
for (const workspaceId of workspaceIdsWithDataSources) {
for (const activeWorkspace of activeWorkspaces) {
await this.messageQueueService.add<MessagingOngoingStaleJobData>(
MessagingOngoingStaleJob.name,
{
workspaceId,
workspaceId: activeWorkspace.id,
},
);
}

View File

@ -2,14 +2,15 @@ import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import snakeCase from 'lodash.snakecase';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { MessageChannelRepository } from 'src/modules/messaging/common/repositories/message-channel.repository';
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@ -24,11 +25,8 @@ export class MessagingMessageChannelSyncStatusMonitoringCronJob {
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(DataSourceEntity, 'metadata')
private readonly dataSourceRepository: Repository<DataSourceEntity>,
@InjectObjectMetadataRepository(MessageChannelWorkspaceEntity)
private readonly messageChannelRepository: MessageChannelRepository,
private readonly billingService: BillingService,
private readonly messagingTelemetryService: MessagingTelemetryService,
) {}
@ -41,22 +39,16 @@ export class MessagingMessageChannelSyncStatusMonitoringCronJob {
message: 'Starting message channel sync status monitoring',
});
const workspaceIds =
await this.billingService.getActiveSubscriptionWorkspaceIds();
const dataSources = await this.dataSourceRepository.find({
const activeWorkspaces = await this.workspaceRepository.find({
where: {
workspaceId: In(workspaceIds),
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
const workspaceIdsWithDataSources = new Set(
dataSources.map((dataSource) => dataSource.workspaceId),
);
for (const workspaceId of workspaceIdsWithDataSources) {
const messageChannels =
await this.messageChannelRepository.getAll(workspaceId);
for (const activeWorkspace of activeWorkspaces) {
const messageChannels = await this.messageChannelRepository.getAll(
activeWorkspace.id,
);
for (const messageChannel of messageChannels) {
if (!messageChannel.syncStatus) {
@ -66,7 +58,7 @@ export class MessagingMessageChannelSyncStatusMonitoringCronJob {
eventName: `message_channel.monitoring.sync_status.${snakeCase(
messageChannel.syncStatus,
)}`,
workspaceId,
workspaceId: activeWorkspace.id,
connectedAccountId: messageChannel.connectedAccountId,
messageChannelId: messageChannel.id,
message: messageChannel.syncStatus,