[2/2] Write CREATED at activation, clean stale onboarding workspaces (#22915)
## Context Follow-up to #22904 (merged), which introduced the `CREATED` activation status (schema provisioned, onboarding incomplete — no billing subscription), its enum migration, and the read path. This PR turns the status on and closes the zombie-workspace leak (~60–110 subscription-less ACTIVE workspaces per day since v2 onboarding, 935+ total). ✅ **Deploy gating satisfied**: #22904's slow enum migration shipped with the release deployed to prod on 2026-07-17, so writing `CREATED` is now safe. Rebased on main (clean, no conflicts) — main's #22943/#22955 guarded-transition rework already handles `CREATED` correctly: the webhook suspend switch only suspends `ACTIVE` workspaces, and `reactivateWorkspace` promotes `CREATED`→`ACTIVE`. ## What this PR does 1. **Write path** — `activateWorkspace` sets `CREATED` instead of `ACTIVE` when the workspace has no billing subscription. `hasWorkspaceAnySubscription` returns true when billing is disabled, so self-hosted workspaces keep going straight to `ACTIVE` — no behavior change outside cloud. 2. **Cleanup** — `CREATED` joins `PENDING_CREATION`/`ONGOING_CREATION` in the existing onboarding cleaning flow (cron + `workspace:clean:onboarding` with `--dry-run`): workspaces older than the same seven-day threshold are soft-deleted, then hard-deleted on a later run. **No suspension step and no emails** — an abandoned onboarding is treated as never having completed, exactly like a workspace stuck in creation. A workspace that subscribes before cleanup exits the flow (`CREATED`→`ACTIVE` synchronously via checkout). 3. **Backfill** — slow instance command moving `ACTIVE` workspaces with no `billingSubscription` row, created since v2 onboarding shipped (2026-07-01), to `CREATED`. Gated on `IS_BILLING_ENABLED` so self-hosted instances are untouched. 4. **Resolves #22904's text-cast TODO on the billing activation update** — this PR only deploys after the enum migration, so the `CREATED`→`ACTIVE` promotion is a plain status-scoped update again. The upgrade-path filters (`activationStatusIn`) keep the `::text` cast: upgrade tooling has to run against databases coming from pre-2.22 versions, so its TODO now points at the real removal trigger (dropping pre-2.22 upgrade support). ## Ops note before deploying The backfilled zombies are all older than seven days, so the first cron run after the backfill **soft-deletes them and the next run destroys them (schema and data), with no user-facing communication**. The backfill also catches any post-July-1 cloud workspace that is ACTIVE without a subscription — including intentionally comped/demo/internal ones if any were created since then (verified locally: the seeded demo workspaces matched). **Run the backfill's SELECT as a dry-run against prod and review the list before deploying.** ## CI note ~~`cross-version-upgrade` (and its `ci-server-status-check` aggregate) is red due to a pre-existing regression on main — `Field metadata "coreWorkflowVersionId" is missing in object metadata workflowVersion` on the seed workspaces.~~ Resolved: the rebase picks up main's #22944/#22961 which fixed that regression. ## Verification Server-side (billing-enabled local instance, Stripe test mode) and through the full onboarding UI in both billing modes: - **Billing enabled, UI**: signup → workspace creation → **`activationStatus: CREATED`** in DB mid-onboarding → profile/invite steps work on the CREATED workspace → plan-required page → no-card trial → app loads, workspace **`ACTIVE`** with a `trialing` subscription (exercises #22904's synchronous promotion). - **Billing disabled, UI**: signup → workspace creation → **`ACTIVE` directly**, no plan step anywhere, app loads — self-hosted behavior unchanged. - **Cleanup**: a `CREATED` workspace backdated 8 days is listed by `workspace:clean:onboarding --dry-run`; the real run soft-deletes it silently (no suspension, no email) and the next run hard-deletes it (workspace row and schema gone). - **Backfill**: synthetic `ACTIVE` no-sub workspaces — created 2026-07-05 flips to `CREATED`, created 2026-06-15 stays `ACTIVE`, subscribed workspaces stay `ACTIVE`; billing-disabled short-circuit returns without touching anything. - Lint + typecheck green.
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
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)
|
||||
// TODO: drop the ::text cast and use In() once upgrades from versions before 2.22 (CREATED enum migration) are no longer supported
|
||||
export const activationStatusIn = (statuses: WorkspaceActivationStatus[]) =>
|
||||
Raw(
|
||||
(alias) => {
|
||||
+1
-1
@@ -10,7 +10,7 @@ import {
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { activationStatusIn } from 'src/engine/core-modules/workspace/utils/activation-status-in.util';
|
||||
import { activationStatusIn } from 'src/database/commands/command-runners/utils/activation-status-in.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { DataSource, QueryRunner } from 'typeorm';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
|
||||
|
||||
const V2_ONBOARDING_RELEASE_DATE = '2026-07-01';
|
||||
|
||||
@RegisteredInstanceCommand('2.23.0', 1784286705000, { type: 'slow' })
|
||||
export class BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand
|
||||
implements SlowInstanceCommand
|
||||
{
|
||||
private readonly logger = new Logger(
|
||||
BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand.name,
|
||||
);
|
||||
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
async runDataMigration(dataSource: DataSource): Promise<void> {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backfilledWorkspaces: { id: string }[] = await dataSource.query(
|
||||
`UPDATE "core"."workspace" "workspace"
|
||||
SET "activationStatus" = 'CREATED'
|
||||
WHERE "workspace"."activationStatus" = 'ACTIVE'
|
||||
AND "workspace"."deletedAt" IS NULL
|
||||
AND "workspace"."createdAt" >= $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "core"."billingSubscription" "billingSubscription"
|
||||
WHERE "billingSubscription"."workspaceId" = "workspace"."id"
|
||||
)
|
||||
RETURNING "workspace"."id"`,
|
||||
[V2_ONBOARDING_RELEASE_DATE],
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Backfilled ${backfilledWorkspaces.length} subscription-less active workspace(s) to CREATED`,
|
||||
);
|
||||
}
|
||||
|
||||
public async up(_queryRunner: QueryRunner): Promise<void> {}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {}
|
||||
}
|
||||
+2
@@ -112,6 +112,7 @@ import { BackfillWorkspaceDatabaseSchemaSlowInstanceCommand } from './2-21/2-21-
|
||||
import { AddLogoFileIdToApplicationRegistrationFastInstanceCommand } from './2-21/2-21-instance-command-fast-1783945979243-add-logo-file-id-to-application-registration';
|
||||
import { AddCalendarEndFieldMetadataIdToViewFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-22/2-22-instance-command-fast-1783956795000-add-calendar-end-field-metadata-id-to-view';
|
||||
import { AddCreatedWorkspaceActivationStatusSlowInstanceCommand } from './2-22/2-22-instance-command-slow-1784106205000-add-created-workspace-activation-status';
|
||||
import { BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784286705000-backfill-created-workspace-activation-status';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -226,4 +227,5 @@ export const INSTANCE_COMMANDS = [
|
||||
AddLogoFileIdToApplicationRegistrationFastInstanceCommand,
|
||||
AddCalendarEndFieldMetadataIdToViewFastInstanceCommand,
|
||||
AddCreatedWorkspaceActivationStatusSlowInstanceCommand,
|
||||
BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand,
|
||||
];
|
||||
|
||||
+7
-10
@@ -407,16 +407,13 @@ export class BillingSubscriptionService {
|
||||
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();
|
||||
const activationResult = await this.workspaceRepository.update(
|
||||
{
|
||||
id: workspaceId,
|
||||
activationStatus: WorkspaceActivationStatus.CREATED,
|
||||
},
|
||||
{ activationStatus: WorkspaceActivationStatus.ACTIVE },
|
||||
);
|
||||
|
||||
if ((activationResult.affected ?? 0) > 0) {
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
|
||||
+1
-1
@@ -12,7 +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 { activationStatusIn } from 'src/database/commands/command-runners/utils/activation-status-in.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
|
||||
+6
-1
@@ -461,6 +461,9 @@ export class WorkspaceService {
|
||||
const executedByVersion =
|
||||
this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
|
||||
|
||||
const hasWorkspaceAnySubscription =
|
||||
await this.billingService.hasWorkspaceAnySubscription(workspaceId);
|
||||
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
@@ -468,7 +471,9 @@ export class WorkspaceService {
|
||||
|
||||
try {
|
||||
await queryRunner.manager.update(WorkspaceEntity, workspaceId, {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
activationStatus: hasWorkspaceAnySubscription
|
||||
? WorkspaceActivationStatus.ACTIVE
|
||||
: WorkspaceActivationStatus.CREATED,
|
||||
});
|
||||
|
||||
await this.upgradeMigrationService.markAsWorkspaceInitial({
|
||||
|
||||
+1
@@ -49,6 +49,7 @@ export class CleanOnboardingWorkspacesCommand extends MigrationCommandRunner {
|
||||
activationStatus: In([
|
||||
WorkspaceActivationStatus.PENDING_CREATION,
|
||||
WorkspaceActivationStatus.ONGOING_CREATION,
|
||||
WorkspaceActivationStatus.CREATED,
|
||||
]),
|
||||
createdAt: LessThan(sevenDaysAgo),
|
||||
},
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ export class CleanOnboardingWorkspacesJob {
|
||||
activationStatus: In([
|
||||
WorkspaceActivationStatus.PENDING_CREATION,
|
||||
WorkspaceActivationStatus.ONGOING_CREATION,
|
||||
WorkspaceActivationStatus.CREATED,
|
||||
]),
|
||||
createdAt: LessThan(sevenDaysAgo),
|
||||
},
|
||||
|
||||
+1
@@ -300,6 +300,7 @@ export class CleanerWorkspaceService {
|
||||
activationStatus: In([
|
||||
WorkspaceActivationStatus.PENDING_CREATION,
|
||||
WorkspaceActivationStatus.ONGOING_CREATION,
|
||||
WorkspaceActivationStatus.CREATED,
|
||||
]),
|
||||
},
|
||||
withDeleted: true,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { PROVISIONED_WORKSPACE_ACTIVATION_STATUSES } from 'twenty-shared/workspace';
|
||||
import { MoreThanOrEqual, QueryRunner, Repository } from 'typeorm';
|
||||
|
||||
import { activationStatusIn } from 'src/engine/core-modules/workspace/utils/activation-status-in.util';
|
||||
import { activationStatusIn } from 'src/database/commands/command-runners/utils/activation-status-in.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
|
||||
Reference in New Issue
Block a user