Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context Since v2 onboarding (#22303), workspaces are activated **before** the billing plan step (now the last onboarding step). Users abandoning at the plan step leave ACTIVE workspaces with a Stripe customer but no subscription (~60–110/day on cloud, 935+ so far), and no cleanup mechanism ever touches them: billing webhooks never fire (no subscription), the suspended-workspaces cron only handles SUSPENDED, the onboarding cron only handles PENDING_CREATION/ONGOING_CREATION. Target lifecycle (across two PRs): `PENDING_CREATION → ONGOING_CREATION → CREATED → ACTIVE → SUSPENDED → deleted`. **`CREATED`** = the workspace schema is provisioned but onboarding is not complete — no billing subscription yet. It is **not** considered active: | Concern | CREATED behavior | |---|---| | Sign-in / invited teammates joining | allowed (invite-team step precedes the plan step) | | Member + metadata loading (app shell) | allowed (user must finish onboarding) | | Permissions | real permission checks (no PENDING-style bypass) | | Version upgrades / workspace migrations | **included** (schema must not drift) | | Messaging/calendar/workflow/etc. crons | **excluded** — no background processing until a plan is chosen | | PLAN_REQUIRED onboarding lock | unchanged (still derived from subscription existence) | ## What this PR does (read path only) The enum addition ships as a **slow** instance command, which can run after deploy — so nothing in this PR ever **writes** `CREATED`. The write path (setting it at activation, the cleanup sweep, the backfill of the existing zombie cohort) is a follow-up PR that ships once this migration has run everywhere. - **twenty-shared**: `CREATED` enum value; `PROVISIONED_WORKSPACE_ACTIVATION_STATUSES` + `isWorkspaceProvisioned` ("schema exists": CREATED | ACTIVE | SUSPENDED), replacing `isWorkspaceActiveOrSuspended` — all call sites (server member loading, access-token workspace-member lookup, front metadata-store gates) meant "has schema/members". - **Slow instance command** (2.22.0): swaps `core.workspace_activationStatus_enum` using the rename→recreate→alter-column idiom. The CHECK constraints on `core.workspace` embed casts to the enum type and would break the swap — the command captures them from `pg_constraint`, drops them, swaps the type, and restores them. - **Pre-migration-safe queries**: Postgres rejects `IN ('CREATED', ...)` when the enum value does not exist yet — even for reads, and the instance-command runner itself queries provisioned workspaces before migrating (a fresh database could never initialize). All provisioned-status filters go through a new `activationStatusIn` util comparing on `"activationStatus"::text`, valid before and after the migration. - **Upgrade path**: workspace iterator, command runner, upgrade-status and workspace-version services iterate CREATED workspaces. Since they now cover more than ACTIVE/SUSPENDED, the stale names were renamed to `ProvisionedWorkspaceCommandRunner`, `hasProvisionedWorkspaces`, `getProvisionedWorkspaceIds`, `loadProvisionedWorkspaces` (the mechanical import rename in old version-command dirs is why this PR carries the `ci:allow-previous-version-upgrade-mutation` label). - **Sign-in**: `throwIfWorkspaceIsNotReadyForSignInUp` accepts CREATED so invited members can join during onboarding (join authorization itself is unchanged — enforced upstream in `checkAccessForSignIn`); `activateWorkspace` idempotent-retry accepts CREATED as a terminal state. - **Transitions out of CREATED** (only write ACTIVE — safe to ship now, dead until the write path lands): the Stripe webhook reactivation branch also promotes CREATED, and `syncSubscriptionToDatabase` promotes synchronously; both gated on `WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES` (Active/Trialing — extracted from `shouldReactivateWorkspace`, behavior-preserving) so an `incomplete` subscription created by the payment-intent flow before payment never promotes the workspace. - Deliberately untouched: all background crons, permission guards, JWT strategy, PLAN_REQUIRED logic, admin panel (renders the raw status string). ## Follow-up PR (after this migration has run) 1. `activateWorkspace` sets `hasWorkspaceAnySubscription ? ACTIVE : CREATED` (billing disabled → always ACTIVE, self-hosted unchanged). 2. Cleanup: suspend CREATED workspaces older than N days (config var), handing them to the existing suspended pipeline (warn → soft-delete → destroy). 3. Backfill: cloud-only slow command moving ACTIVE workspaces with no billingSubscription row (created since Jul 1) to CREATED. ## Verification - Migration exercised against a real database via the command class: up → down → up; `enum_range` and `pg_get_constraintdef` checked after each step (constraints restored against the new type, `DEFAULT 'INACTIVE'` preserved). - Pre-migration safety exercised for real: with the migration rolled back (enum without CREATED), `run-instance-commands` — the exact fresh-database CI path that failed before the `::text` fix — completes cleanly. - End-to-end with a workspace manually set to CREATED and the branch server+front running: sign-in issues tokens, `currentUser` loads workspaceMember(s), the full app loads with no console errors; GraphQL returns `activationStatus: CREATED`. - Workspace creation ran end-to-end locally in **both billing modes** on this branch: - billing disabled: signup → workspace creation → ACTIVE immediately → onboarding completes with no plan step → app loads (unchanged behavior); - billing enabled (Stripe test mode): signup creates the Stripe customer eagerly → activation ends ACTIVE → subscription-less workspace is pinned to the plan-required page → no-card trial checkout creates a `trialing` subscription via `createDirectSubscription`/`syncSubscriptionToDatabase` → app loads. - `twenty-shared` unit tests, server specs on touched services, `lint:diff-with-main` and `typecheck` for shared/server/front all green; full CI green.
This commit is contained in:
@@ -273,7 +273,12 @@ export class SignInUpService {
|
||||
workspace: WorkspaceEntity,
|
||||
user: ExistingUserOrPartialUserWithPicture,
|
||||
) {
|
||||
if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) return;
|
||||
if (
|
||||
workspace.activationStatus === WorkspaceActivationStatus.ACTIVE ||
|
||||
workspace.activationStatus === WorkspaceActivationStatus.CREATED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.userData.type !== 'existingUser') {
|
||||
throw new AuthException(
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { addMilliseconds } from 'date-fns';
|
||||
import { type Request } from 'express';
|
||||
import ms from 'ms';
|
||||
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
|
||||
import { isWorkspaceProvisioned } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
@@ -72,7 +72,7 @@ export class AccessTokenService {
|
||||
|
||||
let workspaceMemberId: string | undefined;
|
||||
|
||||
if (isWorkspaceActiveOrSuspended(workspace)) {
|
||||
if (isWorkspaceProvisioned(workspace)) {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
workspaceMemberId =
|
||||
|
||||
+4
-7
@@ -21,6 +21,7 @@ import {
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES } from 'src/engine/core-modules/billing/constants/workspace-activating-subscription-statuses.constant';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
|
||||
import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
|
||||
@@ -161,7 +162,8 @@ export class BillingWebhookSubscriptionService {
|
||||
}
|
||||
} else if (
|
||||
this.shouldReactivateWorkspace(data) &&
|
||||
workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED
|
||||
(workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED ||
|
||||
workspace.activationStatus === WorkspaceActivationStatus.CREATED)
|
||||
) {
|
||||
await this.workspaceService.reactivateWorkspace(workspaceId);
|
||||
|
||||
@@ -223,12 +225,7 @@ export class BillingWebhookSubscriptionService {
|
||||
): boolean {
|
||||
const status = data.object.status as SubscriptionStatus;
|
||||
|
||||
const activeStatuses = [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
];
|
||||
|
||||
return activeStatuses.includes(status);
|
||||
return WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
async updateBillingSubscriptionItems(
|
||||
|
||||
+11
-4
@@ -3,9 +3,13 @@
|
||||
import chalk from 'chalk';
|
||||
import { Command } from 'nest-commander';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import {
|
||||
type RunOnWorkspaceArgs,
|
||||
WorkspaceCommandRunner,
|
||||
} from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
@@ -14,14 +18,17 @@ import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scope
|
||||
name: 'billing:sync-customer-data',
|
||||
description: 'Sync customer data from Stripe for all active workspaces',
|
||||
})
|
||||
export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
export class BillingSyncCustomerDataCommand extends WorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
protected readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
super(workspaceIteratorService, [
|
||||
WorkspaceActivationStatus.ACTIVE,
|
||||
WorkspaceActivationStatus.SUSPENDED,
|
||||
]);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
|
||||
+11
-4
@@ -3,9 +3,13 @@
|
||||
import { Command, Option } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import {
|
||||
type RunOnWorkspaceArgs,
|
||||
WorkspaceCommandRunner,
|
||||
} from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
|
||||
|
||||
@@ -13,7 +17,7 @@ import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/s
|
||||
name: 'billing:update-subscription-price',
|
||||
description: 'Update subscription price',
|
||||
})
|
||||
export class BillingUpdateSubscriptionPriceCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
export class BillingUpdateSubscriptionPriceCommand extends WorkspaceCommandRunner {
|
||||
private stripePriceIdToUpdate: string;
|
||||
private newStripePriceId: string;
|
||||
private clearUsage = false;
|
||||
@@ -23,7 +27,10 @@ export class BillingUpdateSubscriptionPriceCommand extends ActiveOrSuspendedWork
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
super(workspaceIteratorService, [
|
||||
WorkspaceActivationStatus.ACTIVE,
|
||||
WorkspaceActivationStatus.SUSPENDED,
|
||||
]);
|
||||
}
|
||||
|
||||
@Option({
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
|
||||
export const WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES: SubscriptionStatus[] =
|
||||
[SubscriptionStatus.Active, SubscriptionStatus.Trialing];
|
||||
+33
-2
@@ -5,10 +5,12 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { Not, type Repository } from 'typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-customer.util';
|
||||
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
|
||||
import {
|
||||
@@ -25,6 +27,7 @@ import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entiti
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES } from 'src/engine/core-modules/billing/constants/workspace-activating-subscription-statuses.constant';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
|
||||
@@ -35,15 +38,19 @@ import { StripeSubscriptionService } from 'src/engine/core-modules/billing/strip
|
||||
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
@Injectable()
|
||||
// oxlint-disable-next-line twenty/inject-workspace-repository
|
||||
export class BillingSubscriptionService {
|
||||
protected readonly logger = new Logger(BillingSubscriptionService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||
private readonly billingPriceService: BillingPriceService,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
@@ -395,6 +402,30 @@ export class BillingSubscriptionService {
|
||||
`Subscription synced to database: ${subscription.id} for workspace: ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (
|
||||
WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES.includes(
|
||||
currentBillingSubscription.status,
|
||||
)
|
||||
) {
|
||||
// TODO: drop the ::text cast once the CREATED enum migration has run everywhere (follow-up PR of #22904)
|
||||
const activationResult = await this.workspaceRepository
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ activationStatus: WorkspaceActivationStatus.ACTIVE })
|
||||
.where('id = :workspaceId AND "activationStatus"::text = :fromStatus', {
|
||||
workspaceId,
|
||||
fromStatus: WorkspaceActivationStatus.CREATED,
|
||||
})
|
||||
.execute();
|
||||
|
||||
if ((activationResult.affected ?? 0) > 0) {
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
'workspaceEntity',
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return currentBillingSubscription;
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -34,9 +34,7 @@ export class StripeSubscriptionService {
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return subscription.data[0].customer
|
||||
? subscription.data[0].customer
|
||||
: undefined;
|
||||
return subscription.data[0]?.customer ?? undefined;
|
||||
}
|
||||
|
||||
async collectLastInvoice(stripeSubscriptionId: string) {
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ export class UpgradeStatusCommand extends CommandRunner {
|
||||
const lines: string[] = [chalk.bold.underline('Workspace')];
|
||||
|
||||
if (upToDate.length === 0 && behind.length === 0 && failed.length === 0) {
|
||||
lines.push(chalk.dim(' No active/suspended workspaces found'));
|
||||
lines.push(chalk.dim(' No provisioned workspaces found'));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
+3
-3
@@ -59,7 +59,7 @@ export class InstanceCommandRunnerService {
|
||||
await command.up(queryRunner);
|
||||
|
||||
const workspaceIds =
|
||||
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds({
|
||||
await this.workspaceVersionService.getProvisionedWorkspaceIds({
|
||||
queryRunner,
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export class InstanceCommandRunnerService {
|
||||
}
|
||||
|
||||
const workspaceIds =
|
||||
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
|
||||
await this.workspaceVersionService.getProvisionedWorkspaceIds();
|
||||
|
||||
await this.upgradeMigrationService.recordUpgradeMigration({
|
||||
name,
|
||||
@@ -149,7 +149,7 @@ export class InstanceCommandRunnerService {
|
||||
this.logger.log(`${name} data migration completed`);
|
||||
} catch (error) {
|
||||
const workspaceIds =
|
||||
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
|
||||
await this.workspaceVersionService.getProvisionedWorkspaceIds();
|
||||
|
||||
await this.upgradeMigrationService.recordUpgradeMigration({
|
||||
name,
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { DiscoveryService } from '@nestjs/core';
|
||||
|
||||
import { type ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { type ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-workspace.command-runner';
|
||||
import { type WorkspaceCommandRunner } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import {
|
||||
TWENTY_ALL_VERSIONS,
|
||||
@@ -19,7 +19,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type WorkspaceCommand =
|
||||
| WorkspaceCommandRunner
|
||||
| ActiveOrSuspendedWorkspaceCommandRunner;
|
||||
| ProvisionedWorkspaceCommandRunner;
|
||||
|
||||
export type RegisteredFastInstanceCommand = {
|
||||
name: string;
|
||||
|
||||
+4
-4
@@ -189,7 +189,7 @@ export class UpgradeMigrationService {
|
||||
// isInitial records are excluded — they represent activation
|
||||
// state, not execution progress.
|
||||
async getLastAttemptedCommandNameOrThrow(
|
||||
allActiveOrSuspendedWorkspaceIds: string[],
|
||||
allProvisionedWorkspaceIds: string[],
|
||||
): Promise<{
|
||||
name: string;
|
||||
status: UpgradeMigrationStatus;
|
||||
@@ -210,10 +210,10 @@ export class UpgradeMigrationService {
|
||||
)`,
|
||||
);
|
||||
|
||||
if (allActiveOrSuspendedWorkspaceIds.length > 0) {
|
||||
if (allProvisionedWorkspaceIds.length > 0) {
|
||||
queryBuilder.andWhere(
|
||||
'(migration."workspaceId" IS NULL OR migration."workspaceId" IN (:...allActiveOrSuspendedWorkspaceIds))',
|
||||
{ allActiveOrSuspendedWorkspaceIds },
|
||||
'(migration."workspaceId" IS NULL OR migration."workspaceId" IN (:...allProvisionedWorkspaceIds))',
|
||||
{ allProvisionedWorkspaceIds },
|
||||
);
|
||||
} else {
|
||||
queryBuilder.andWhere('migration."workspaceId" IS NULL');
|
||||
|
||||
+22
-22
@@ -77,19 +77,19 @@ export class UpgradeSequenceRunnerService {
|
||||
sequence: UpgradeStep[];
|
||||
options: ParsedUpgradeCommandOptions;
|
||||
}): Promise<UpgradeSequenceRunnerReport> {
|
||||
const allActiveOrSuspendedWorkspaceIds =
|
||||
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
|
||||
const allProvisionedWorkspaceIds =
|
||||
await this.workspaceVersionService.getProvisionedWorkspaceIds();
|
||||
|
||||
const startCursor = await this.resolveStartCursor({
|
||||
sequence,
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
});
|
||||
|
||||
let totalSuccesses = 0;
|
||||
let totalFailures = 0;
|
||||
let cursor = startCursor;
|
||||
let workspaceCursors = await this.fetchWorkspaceCursors(
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
);
|
||||
|
||||
while (cursor < sequence.length) {
|
||||
@@ -131,7 +131,7 @@ export class UpgradeSequenceRunnerService {
|
||||
|
||||
await this.runInstanceStep({
|
||||
instanceStep: step,
|
||||
skipDataMigration: allActiveOrSuspendedWorkspaceIds.length === 0,
|
||||
skipDataMigration: allProvisionedWorkspaceIds.length === 0,
|
||||
});
|
||||
|
||||
await this.upgradeAwareEntityMetadataAdapter.refresh();
|
||||
@@ -149,7 +149,7 @@ export class UpgradeSequenceRunnerService {
|
||||
const report = await this.resumeWorkspaceCommandsFromCursors({
|
||||
workspaceCommandsSegment,
|
||||
workspaceCursors,
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
options,
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ export class UpgradeSequenceRunnerService {
|
||||
cursor += workspaceCommandsSegment.length;
|
||||
|
||||
workspaceCursors = await this.fetchWorkspaceCursors(
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,14 +185,14 @@ export class UpgradeSequenceRunnerService {
|
||||
|
||||
private async resolveStartCursor({
|
||||
sequence,
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
}: {
|
||||
sequence: UpgradeStep[];
|
||||
allActiveOrSuspendedWorkspaceIds: string[];
|
||||
allProvisionedWorkspaceIds: string[];
|
||||
}): Promise<number> {
|
||||
const lastAttempted =
|
||||
await this.upgradeMigrationService.getLastAttemptedCommandNameOrThrow(
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
);
|
||||
|
||||
const lastAttemptedCursor =
|
||||
@@ -219,7 +219,7 @@ export class UpgradeSequenceRunnerService {
|
||||
|
||||
await this.validateWorkspaceCursorsAreInWorkspaceSegment({
|
||||
sequence,
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
workspaceSliceBounds,
|
||||
});
|
||||
|
||||
@@ -231,17 +231,17 @@ export class UpgradeSequenceRunnerService {
|
||||
}
|
||||
|
||||
private async validateWorkspaceCursorsAreInWorkspaceSegment({
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
sequence,
|
||||
workspaceSliceBounds: { startCursor, endCursor },
|
||||
}: {
|
||||
sequence: UpgradeStep[];
|
||||
allActiveOrSuspendedWorkspaceIds: string[];
|
||||
allProvisionedWorkspaceIds: string[];
|
||||
workspaceSliceBounds: { startCursor: number; endCursor: number };
|
||||
}): Promise<void> {
|
||||
const workspaceCursors =
|
||||
await this.upgradeMigrationService.getWorkspaceLastAttemptedCommandNameOrThrow(
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
);
|
||||
const precedingStep =
|
||||
startCursor > 0 ? sequence[startCursor - 1] : undefined;
|
||||
@@ -293,10 +293,10 @@ export class UpgradeSequenceRunnerService {
|
||||
}
|
||||
|
||||
private async fetchWorkspaceCursors(
|
||||
allActiveOrSuspendedWorkspaceIds: string[],
|
||||
allProvisionedWorkspaceIds: string[],
|
||||
): Promise<Map<string, WorkspaceLastAttemptedCommand>> {
|
||||
return this.upgradeMigrationService.getWorkspaceLastAttemptedCommandNameOrThrow(
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -343,16 +343,16 @@ export class UpgradeSequenceRunnerService {
|
||||
private async resumeWorkspaceCommandsFromCursors({
|
||||
workspaceCommandsSegment,
|
||||
workspaceCursors,
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
options,
|
||||
}: {
|
||||
workspaceCommandsSegment: WorkspaceUpgradeStep[];
|
||||
workspaceCursors: Map<string, WorkspaceLastAttemptedCommand>;
|
||||
allActiveOrSuspendedWorkspaceIds: string[];
|
||||
allProvisionedWorkspaceIds: string[];
|
||||
options: ParsedUpgradeCommandOptions;
|
||||
}): Promise<WorkspaceIteratorReport> {
|
||||
const workspaceIds = this.deriveWorkspaceIdsToProcess({
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
options,
|
||||
});
|
||||
|
||||
@@ -384,17 +384,17 @@ export class UpgradeSequenceRunnerService {
|
||||
}
|
||||
|
||||
private deriveWorkspaceIdsToProcess({
|
||||
allActiveOrSuspendedWorkspaceIds,
|
||||
allProvisionedWorkspaceIds,
|
||||
options,
|
||||
}: {
|
||||
allActiveOrSuspendedWorkspaceIds: string[];
|
||||
allProvisionedWorkspaceIds: string[];
|
||||
options: ParsedUpgradeCommandOptions;
|
||||
}): string[] {
|
||||
if (isDefined(options.workspaceIds) && options.workspaceIds.length > 0) {
|
||||
return options.workspaceIds;
|
||||
}
|
||||
|
||||
let workspaceIds = allActiveOrSuspendedWorkspaceIds;
|
||||
let workspaceIds = allProvisionedWorkspaceIds;
|
||||
|
||||
if (isDefined(options.startFromWorkspaceId)) {
|
||||
workspaceIds = workspaceIds.filter(
|
||||
|
||||
+8
-9
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { UpgradeHealthEnum } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { PROVISIONED_WORKSPACE_ACTIVATION_STATUSES } from 'twenty-shared/workspace';
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
@@ -12,6 +12,7 @@ import { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/servi
|
||||
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
|
||||
import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util';
|
||||
|
||||
import { activationStatusIn } from 'src/engine/core-modules/workspace/utils/activation-status-in.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
@@ -102,8 +103,7 @@ export class UpgradeStatusService {
|
||||
async getWorkspaceStatuses(
|
||||
filterWorkspaceIds?: string[],
|
||||
): Promise<WorkspaceUpgradeStatus[]> {
|
||||
const workspaces =
|
||||
await this.loadActiveOrSuspendedWorkspaces(filterWorkspaceIds);
|
||||
const workspaces = await this.loadProvisionedWorkspaces(filterWorkspaceIds);
|
||||
|
||||
if (filterWorkspaceIds) {
|
||||
const foundIds = new Set(workspaces.map((workspace) => workspace.id));
|
||||
@@ -111,7 +111,7 @@ export class UpgradeStatusService {
|
||||
for (const requestedId of filterWorkspaceIds) {
|
||||
if (!foundIds.has(requestedId)) {
|
||||
this.logger.warn(
|
||||
`Workspace ${requestedId} not found or not active/suspended`,
|
||||
`Workspace ${requestedId} not found or not provisioned`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,7 @@ export class UpgradeStatusService {
|
||||
};
|
||||
}
|
||||
|
||||
private async loadActiveOrSuspendedWorkspaces(
|
||||
private async loadProvisionedWorkspaces(
|
||||
workspaceIds?: string[],
|
||||
): Promise<Pick<WorkspaceEntity, 'id' | 'displayName'>[]> {
|
||||
return this.workspaceRepository.find({
|
||||
@@ -313,10 +313,9 @@ export class UpgradeStatusService {
|
||||
...(workspaceIds && workspaceIds.length > 0
|
||||
? { id: In(workspaceIds) }
|
||||
: {}),
|
||||
activationStatus: In([
|
||||
WorkspaceActivationStatus.ACTIVE,
|
||||
WorkspaceActivationStatus.SUSPENDED,
|
||||
]),
|
||||
activationStatus: activationStatusIn(
|
||||
PROVISIONED_WORKSPACE_ACTIVATION_STATUSES,
|
||||
),
|
||||
},
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
@@ -152,9 +152,7 @@ describe('UserService', () => {
|
||||
});
|
||||
|
||||
describe('loadWorkspaceMember', () => {
|
||||
it('returns null when workspace is not active/suspended', async () => {
|
||||
// isWorkspaceActiveOrSuspendedSpy.mockReturnValue(false);
|
||||
|
||||
it('returns null when workspace is not provisioned', async () => {
|
||||
const res = await service.loadWorkspaceMember(
|
||||
{ id: 'u1' } as Pick<AuthContextUser, 'id'>,
|
||||
{ id: 'w1' } as WorkspaceEntity,
|
||||
@@ -194,10 +192,31 @@ describe('UserService', () => {
|
||||
});
|
||||
expect(res).toEqual({ id: 'wm1', userId: 'u1' });
|
||||
});
|
||||
|
||||
it('fetches from workspace member repo when workspace is created', async () => {
|
||||
jest.spyOn(mockWorkspaceMemberRepo, 'findOne').mockResolvedValue({
|
||||
id: 'wm1',
|
||||
userId: 'u1',
|
||||
} as WorkspaceMemberWorkspaceEntity);
|
||||
|
||||
jest
|
||||
.spyOn(globalWorkspaceOrmManager, 'getRepository')
|
||||
.mockResolvedValue(mockWorkspaceMemberRepo);
|
||||
|
||||
const res = await service.loadWorkspaceMember(
|
||||
{ id: 'u1' } as Pick<AuthContextUser, 'id'>,
|
||||
{
|
||||
id: 'w1',
|
||||
activationStatus: WorkspaceActivationStatus.CREATED,
|
||||
} as WorkspaceEntity,
|
||||
);
|
||||
|
||||
expect(res).toEqual({ id: 'wm1', userId: 'u1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadWorkspaceMembers', () => {
|
||||
it('returns [] when workspace is not active/suspended', async () => {
|
||||
it('returns [] when workspace is not provisioned', async () => {
|
||||
const res = await service.loadWorkspaceMembers({
|
||||
id: 'w1',
|
||||
activationStatus: WorkspaceActivationStatus.INACTIVE,
|
||||
@@ -231,7 +250,7 @@ describe('UserService', () => {
|
||||
});
|
||||
|
||||
describe('loadDeletedWorkspaceMembersOnly', () => {
|
||||
it('returns [] when workspace is not active/suspended', async () => {
|
||||
it('returns [] when workspace is not provisioned', async () => {
|
||||
const res = await service.loadDeletedWorkspaceMembersOnly({
|
||||
id: 'w1',
|
||||
activationStatus: WorkspaceActivationStatus.INACTIVE,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
isWorkspaceActiveOrSuspended,
|
||||
isWorkspaceProvisioned,
|
||||
WorkspaceActivationStatus,
|
||||
} from 'twenty-shared/workspace';
|
||||
import { type QueryRunner, In, IsNull, Not, Repository } from 'typeorm';
|
||||
@@ -98,7 +98,7 @@ export class UserService {
|
||||
const refreshedWorkspace =
|
||||
await this.refreshWorkspaceIfPendingOrOngoingCreation(workspace);
|
||||
|
||||
if (!isWorkspaceActiveOrSuspended(refreshedWorkspace)) {
|
||||
if (!isWorkspaceProvisioned(refreshedWorkspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class UserService {
|
||||
const refreshedWorkspace =
|
||||
await this.refreshWorkspaceIfPendingOrOngoingCreation(workspace);
|
||||
|
||||
if (!isWorkspaceActiveOrSuspended(refreshedWorkspace)) {
|
||||
if (!isWorkspaceProvisioned(refreshedWorkspace)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ export class UserService {
|
||||
workspace: Pick<WorkspaceEntity, 'id' | 'activationStatus'>;
|
||||
userIds: string[];
|
||||
}): Promise<WorkspaceMemberWorkspaceEntity[]> {
|
||||
if (!isWorkspaceActiveOrSuspended(workspace) || userIds.length === 0) {
|
||||
if (!isWorkspaceProvisioned(workspace) || userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export class UserService {
|
||||
async loadDeletedWorkspaceMembersOnly(
|
||||
workspace: Pick<WorkspaceEntity, 'id' | 'activationStatus'>,
|
||||
) {
|
||||
if (!isWorkspaceActiveOrSuspended(workspace)) {
|
||||
if (!isWorkspaceProvisioned(workspace)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -366,7 +366,10 @@ export class WorkspaceService {
|
||||
});
|
||||
|
||||
if (
|
||||
existingWorkspace?.activationStatus === WorkspaceActivationStatus.ACTIVE
|
||||
existingWorkspace?.activationStatus ===
|
||||
WorkspaceActivationStatus.ACTIVE ||
|
||||
existingWorkspace?.activationStatus ===
|
||||
WorkspaceActivationStatus.CREATED
|
||||
) {
|
||||
return existingWorkspace;
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Raw } from 'typeorm';
|
||||
|
||||
// TODO: drop the ::text cast and use In() once the CREATED enum migration has run everywhere (follow-up PR of #22904)
|
||||
export const activationStatusIn = (statuses: WorkspaceActivationStatus[]) =>
|
||||
Raw(
|
||||
(alias) => {
|
||||
const quotedAlias = alias
|
||||
.split('.')
|
||||
.map((aliasPart) => `"${aliasPart.replace(/"/g, '')}"`)
|
||||
.join('.');
|
||||
|
||||
return `${quotedAlias}::text IN (:...activationStatusValues)`;
|
||||
},
|
||||
{
|
||||
activationStatusValues: statuses,
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user