[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:
Charles Bochet
2026-07-17 13:26:46 +02:00
committed by GitHub
parent 4a7324c0c8
commit 2e671342f5
11 changed files with 71 additions and 15 deletions
@@ -0,0 +1,18 @@
import { type WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Raw } from 'typeorm';
// 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) => {
const quotedAlias = alias
.split('.')
.map((aliasPart) => `"${aliasPart.replace(/"/g, '')}"`)
.join('.');
return `${quotedAlias}::text IN (:...activationStatusValues)`;
},
{
activationStatusValues: statuses,
},
);
@@ -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';
@@ -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> {}
}
@@ -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,
];