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:
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user