Fix Active Workspaces check (#6084)

We have recently deprecated our subscriptionStatus on workspace to
replace it by a check on existing subscription (+ freeAccess
featureFlag) but the logic was not properly implemented
This commit is contained in:
Charles Bochet
2024-07-01 16:45:56 +02:00
committed by GitHub
parent bb627a91e2
commit 88915291d9
5 changed files with 87 additions and 52 deletions

View File

@ -4,34 +4,68 @@ import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import {
BillingSubscription,
SubscriptionStatus,
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
FeatureFlagEntity,
FeatureFlagKeys,
} from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@Injectable() @Injectable()
export class BillingService { export class BillingService {
protected readonly logger = new Logger(BillingService.name); protected readonly logger = new Logger(BillingService.name);
constructor( constructor(
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
) {} ) {}
async getActiveSubscriptionWorkspaceIds() { async getActiveSubscriptionWorkspaceIds() {
return ( if (!this.environmentService.get('IS_BILLING_ENABLED')) {
await this.workspaceRepository.find({ return (await this.workspaceRepository.find({ select: ['id'] })).map(
where: this.environmentService.get('IS_BILLING_ENABLED') (workspace) => workspace.id,
? { );
currentBillingSubscription: { }
status: In([
SubscriptionStatus.Active, const activeSubscriptions = await this.billingSubscriptionRepository.find({
SubscriptionStatus.Trialing, where: {
SubscriptionStatus.PastDue, status: In([
]), SubscriptionStatus.Active,
}, SubscriptionStatus.Trialing,
} SubscriptionStatus.PastDue,
: {}, ]),
select: ['id'], },
}) select: ['workspaceId'],
).map((workspace) => workspace.id); });
const freeAccessFeatureFlags = await this.featureFlagRepository.find({
where: {
key: FeatureFlagKeys.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

@ -84,9 +84,6 @@ export class Workspace {
@OneToMany(() => FeatureFlagEntity, (featureFlag) => featureFlag.workspace) @OneToMany(() => FeatureFlagEntity, (featureFlag) => featureFlag.workspace)
featureFlags: Relation<FeatureFlagEntity[]>; featureFlags: Relation<FeatureFlagEntity[]>;
@Field({ nullable: true })
currentBillingSubscription: BillingSubscription;
@Field({ nullable: true }) @Field({ nullable: true })
workspaceMembersCount: number; workspaceMembersCount: number;

View File

@ -118,7 +118,7 @@ export class WorkspaceResolver {
return this.workspaceCacheVersionService.getVersion(workspace.id); return this.workspaceCacheVersionService.getVersion(workspace.id);
} }
@ResolveField(() => BillingSubscription) @ResolveField(() => BillingSubscription, { nullable: true })
async currentBillingSubscription( async currentBillingSubscription(
@Parent() workspace: Workspace, @Parent() workspace: Workspace,
): Promise<BillingSubscription | null> { ): Promise<BillingSubscription | null> {

View File

@ -2,27 +2,29 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander'; import { Command, CommandRunner, Option } from 'nest-commander';
import { FindOptionsWhere, In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { getDryRunLogHeader } from 'src/utils/get-dry-run-log-header';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { getDryRunLogHeader } from 'src/utils/get-dry-run-log-header';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { LoadServiceWithWorkspaceContext } from 'src/engine/twenty-orm/context/load-service-with-workspace.context';
type DeleteIncompleteWorkspacesCommandOptions = { type DeleteWorkspacesCommandOptions = {
dryRun?: boolean; dryRun?: boolean;
workspaceIds?: string[]; workspaceIds: string[];
}; };
@Command({ @Command({
name: 'workspace:delete-incomplete', name: 'workspace:delete',
description: 'Delete incomplete workspaces', description: 'Delete workspace',
}) })
export class DeleteIncompleteWorkspacesCommand extends CommandRunner { export class DeleteWorkspacesCommand extends CommandRunner {
private readonly logger = new Logger(DeleteIncompleteWorkspacesCommand.name); private readonly logger = new Logger(DeleteWorkspacesCommand.name);
constructor( constructor(
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly loadServiceWithWorkspaceContext: LoadServiceWithWorkspaceContext,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly dataSourceService: DataSourceService, private readonly dataSourceService: DataSourceService,
@ -42,7 +44,7 @@ export class DeleteIncompleteWorkspacesCommand extends CommandRunner {
@Option({ @Option({
flags: '-w, --workspace-ids [workspace_ids]', flags: '-w, --workspace-ids [workspace_ids]',
description: 'comma separated workspace ids', description: 'comma separated workspace ids',
required: false, required: true,
}) })
parseWorkspaceIds(value: string): string[] { parseWorkspaceIds(value: string): string[] {
return value.split(','); return value.split(',');
@ -50,41 +52,43 @@ export class DeleteIncompleteWorkspacesCommand extends CommandRunner {
async run( async run(
_passedParam: string[], _passedParam: string[],
options: DeleteIncompleteWorkspacesCommandOptions, options: DeleteWorkspacesCommandOptions,
): Promise<void> { ): Promise<void> {
const where: FindOptionsWhere<Workspace> = { const workspaces = await this.workspaceRepository.find({
currentBillingSubscription: { status: SubscriptionStatus.Incomplete }, where: { id: In(options.workspaceIds) },
}; });
if (options.workspaceIds) {
where.id = In(options.workspaceIds);
}
const incompleteWorkspaces = await this.workspaceRepository.findBy(where);
const dataSources = const dataSources =
await this.dataSourceService.getManyDataSourceMetadata(); await this.dataSourceService.getManyDataSourceMetadata();
const workspaceIdsWithSchema = dataSources.map( const workspaceIdsWithSchema = dataSources.map(
(dataSource) => dataSource.workspaceId, (dataSource) => dataSource.workspaceId,
); );
const incompleteWorkspacesToDelete = incompleteWorkspaces.filter(
(incompleteWorkspace) => const workspacesToDelete = workspaces.filter((Workspace) =>
workspaceIdsWithSchema.includes(incompleteWorkspace.id), workspaceIdsWithSchema.includes(Workspace.id),
); );
if (incompleteWorkspacesToDelete.length) { if (workspacesToDelete.length) {
this.logger.log( this.logger.log(
`Running Deleting incomplete workspaces on ${incompleteWorkspacesToDelete.length} workspaces`, `Running Deleting workspaces on ${workspacesToDelete.length} workspaces`,
); );
} }
for (const incompleteWorkspace of incompleteWorkspacesToDelete) { for (const workspace of workspacesToDelete) {
this.logger.log( this.logger.log(
`${getDryRunLogHeader(options.dryRun)}Deleting workspace ${ `${getDryRunLogHeader(options.dryRun)}Deleting workspace ${
incompleteWorkspace.id workspace.id
} name: '${incompleteWorkspace.displayName}'`, } name: '${workspace.displayName}'`,
); );
const workspaceServiceInstance =
await this.loadServiceWithWorkspaceContext.load(
this.workspaceService,
workspace.id,
);
if (!options.dryRun) { if (!options.dryRun) {
await this.workspaceService.softDeleteWorkspace(incompleteWorkspace.id); await workspaceServiceInstance.softDeleteWorkspace(workspace.id);
} }
} }
} }

View File

@ -2,12 +2,12 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DeleteIncompleteWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/delete-incomplete-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { CleanInactiveWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command'; import { CleanInactiveWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command';
import { StartCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/start-clean-inactive-workspaces.cron.command'; import { StartCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/start-clean-inactive-workspaces.cron.command';
import { StopCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command'; import { StopCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { DeleteWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/delete-workspaces.command';
@Module({ @Module({
imports: [ imports: [
@ -16,7 +16,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
DataSourceModule, DataSourceModule,
], ],
providers: [ providers: [
DeleteIncompleteWorkspacesCommand, DeleteWorkspacesCommand,
CleanInactiveWorkspacesCommand, CleanInactiveWorkspacesCommand,
StartCleanInactiveWorkspacesCronCommand, StartCleanInactiveWorkspacesCronCommand,
StopCleanInactiveWorkspacesCronCommand, StopCleanInactiveWorkspacesCronCommand,