8e03921372
## 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.
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
import { Test, type TestingModule } from '@nestjs/testing';
|
|
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
|
|
|
|
import { config } from 'dotenv';
|
|
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
|
import { DataSource, type Repository } from 'typeorm';
|
|
|
|
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
|
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
|
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
|
import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service';
|
|
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
|
import {
|
|
UpgradeSequenceReaderService,
|
|
type UpgradeStep,
|
|
type WorkspaceUpgradeStep,
|
|
} from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
|
import { UpgradeSequenceRunnerService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service';
|
|
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
|
|
import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/workspace-command-runner.service';
|
|
import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
|
|
import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter';
|
|
import {
|
|
SEED_APPLE_WORKSPACE_ID,
|
|
SEED_EMPTY_WORKSPACE_3_ID,
|
|
SEED_EMPTY_WORKSPACE_4_ID,
|
|
SEED_YCOMBINATOR_WORKSPACE_ID,
|
|
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
|
import { createWorkspace } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util';
|
|
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
|
|
|
|
jest.useRealTimers();
|
|
|
|
config({
|
|
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
|
|
override: true,
|
|
});
|
|
|
|
export const WS_1 = SEED_APPLE_WORKSPACE_ID;
|
|
export const WS_2 = SEED_YCOMBINATOR_WORKSPACE_ID;
|
|
export const WS_3 = SEED_EMPTY_WORKSPACE_3_ID;
|
|
export const WS_4 = SEED_EMPTY_WORKSPACE_4_ID;
|
|
|
|
const FK_WORKSPACE_FIXTURES = [
|
|
{
|
|
workspaceId: WS_3,
|
|
applicationId: 'f1c0ffee-0000-4000-8000-0000000000c3',
|
|
subdomain: 'upgrade-test-fixture-3',
|
|
},
|
|
{
|
|
workspaceId: WS_4,
|
|
applicationId: 'f1c0ffee-0000-4000-8000-0000000000c4',
|
|
subdomain: 'upgrade-test-fixture-4',
|
|
},
|
|
];
|
|
|
|
const seedEmptyWorkspaces = async (dataSource: DataSource) => {
|
|
const queryRunner = dataSource.createQueryRunner();
|
|
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
|
|
try {
|
|
for (const {
|
|
workspaceId,
|
|
applicationId,
|
|
subdomain,
|
|
} of FK_WORKSPACE_FIXTURES) {
|
|
await createWorkspace({
|
|
queryRunner,
|
|
schemaName: 'core',
|
|
createWorkspaceInput: {
|
|
id: workspaceId,
|
|
displayName: subdomain,
|
|
subdomain,
|
|
inviteHash: `${subdomain}.dev-invite-hash`,
|
|
logo: '',
|
|
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
|
isTwoFactorAuthenticationEnforced: false,
|
|
workspaceCustomApplicationId: applicationId,
|
|
},
|
|
});
|
|
|
|
await queryRunner.manager
|
|
.createQueryBuilder()
|
|
.insert()
|
|
.into(ApplicationEntity)
|
|
.values({
|
|
id: applicationId,
|
|
universalIdentifier: applicationId,
|
|
name: 'upgrade-test-fixture',
|
|
sourcePath: '',
|
|
workspaceId,
|
|
})
|
|
.orIgnore()
|
|
.execute();
|
|
}
|
|
|
|
await queryRunner.commitTransaction();
|
|
} catch (error) {
|
|
await queryRunner.rollbackTransaction();
|
|
throw error;
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
};
|
|
|
|
const EXECUTED_BY_VERSION = '42.42.42';
|
|
|
|
const noopAsync = async () => {};
|
|
|
|
export const makeStep = (
|
|
kind: UpgradeStep['kind'],
|
|
name: string,
|
|
): UpgradeStep => {
|
|
const command =
|
|
kind === 'workspace'
|
|
? { runOnWorkspace: noopAsync }
|
|
: kind === 'slow-instance'
|
|
? { up: noopAsync, down: noopAsync, runDataMigration: noopAsync }
|
|
: { up: noopAsync, down: noopAsync };
|
|
|
|
return {
|
|
kind,
|
|
name,
|
|
command,
|
|
version: '1.21.0',
|
|
timestamp: 0,
|
|
} as unknown as UpgradeStep;
|
|
};
|
|
|
|
export const makeFastInstance = (name: string) =>
|
|
makeStep('fast-instance', name);
|
|
|
|
export const makeSlowInstance = (name: string) =>
|
|
makeStep('slow-instance', name);
|
|
|
|
export const makeWorkspace = (name: string) =>
|
|
makeStep('workspace', name) as WorkspaceUpgradeStep;
|
|
|
|
let mockActiveWorkspaceIds: string[] = [];
|
|
|
|
export const setMockActiveWorkspaceIds = (ids: string[]) => {
|
|
mockActiveWorkspaceIds = ids;
|
|
};
|
|
|
|
export const DEFAULT_OPTIONS = {
|
|
workspaceIds: undefined,
|
|
startFromWorkspaceId: undefined,
|
|
workspaceCountLimit: undefined,
|
|
dryRun: false,
|
|
verbose: false,
|
|
};
|
|
|
|
type IntegrationTestModule = Awaited<
|
|
ReturnType<typeof createUpgradeSequenceRunnerIntegrationTestModule>
|
|
>;
|
|
|
|
export type IntegrationTestContext = {
|
|
[K in keyof IntegrationTestModule]: IntegrationTestModule[K];
|
|
};
|
|
|
|
export const createUpgradeSequenceRunnerIntegrationTestModule = async () => {
|
|
const dataSource = new DataSource({
|
|
type: 'postgres',
|
|
url: process.env.PG_DATABASE_URL,
|
|
schema: 'core',
|
|
entities: [
|
|
'src/engine/core-modules/**/*.entity.ts',
|
|
'src/engine/metadata-modules/**/*.entity.ts',
|
|
],
|
|
synchronize: false,
|
|
});
|
|
|
|
await dataSource.initialize();
|
|
|
|
await seedEmptyWorkspaces(dataSource);
|
|
|
|
const migrationRepo: Repository<UpgradeMigrationEntity> =
|
|
dataSource.getRepository(UpgradeMigrationEntity);
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
{
|
|
provide: getRepositoryToken(UpgradeMigrationEntity),
|
|
useValue: migrationRepo,
|
|
},
|
|
{
|
|
provide: getDataSourceToken(),
|
|
useValue: dataSource,
|
|
},
|
|
{
|
|
provide: TwentyConfigService,
|
|
useValue: {
|
|
get: (key: string) =>
|
|
key === 'APP_VERSION' ? EXECUTED_BY_VERSION : undefined,
|
|
},
|
|
},
|
|
UpgradeMigrationService,
|
|
{
|
|
provide: WorkspaceVersionService,
|
|
useValue: {
|
|
getProvisionedWorkspaceIds: jest
|
|
.fn()
|
|
.mockImplementation(async () => mockActiveWorkspaceIds),
|
|
hasProvisionedWorkspaces: jest
|
|
.fn()
|
|
.mockImplementation(async () => mockActiveWorkspaceIds.length > 0),
|
|
},
|
|
},
|
|
{
|
|
provide: UpgradeSequenceReaderService,
|
|
useFactory: () => new UpgradeSequenceReaderService({} as any),
|
|
},
|
|
{
|
|
provide: UpgradeStatusService,
|
|
useValue: {
|
|
invalidateInstanceAndAllWorkspacesStatus: jest
|
|
.fn()
|
|
.mockResolvedValue(undefined),
|
|
},
|
|
},
|
|
InstanceCommandRunnerService,
|
|
WorkspaceCommandRunnerService,
|
|
{
|
|
provide: WorkspaceIteratorService,
|
|
useValue: {
|
|
iterate: jest.fn().mockImplementation(async (args: any) => {
|
|
const { callback, workspaceIds } = args;
|
|
const ids = workspaceIds ?? [WS_1];
|
|
const report = { fail: [] as any[], success: [] as any[] };
|
|
|
|
for (const [index, workspaceId] of ids.entries()) {
|
|
try {
|
|
await callback({
|
|
workspaceId,
|
|
index,
|
|
total: ids.length,
|
|
dataSource,
|
|
});
|
|
report.success.push({ workspaceId });
|
|
} catch (error) {
|
|
report.fail.push({ error, workspaceId });
|
|
}
|
|
}
|
|
|
|
return report;
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
provide: UpgradeAwareEntityMetadataAdapter,
|
|
useValue: {
|
|
refresh: jest.fn().mockResolvedValue(undefined),
|
|
isEntityAvailable: jest.fn().mockReturnValue(true),
|
|
getHiddenColumnPropertyNames: jest.fn().mockReturnValue(new Set()),
|
|
},
|
|
},
|
|
UpgradeSequenceRunnerService,
|
|
],
|
|
}).compile();
|
|
|
|
const runner = module.get(UpgradeSequenceRunnerService);
|
|
|
|
jest.spyOn(runner['logger'], 'log').mockImplementation();
|
|
jest.spyOn(runner['logger'], 'error').mockImplementation();
|
|
jest.spyOn(runner['logger'], 'warn').mockImplementation();
|
|
|
|
const instanceCommandRunnerService = module.get(InstanceCommandRunnerService);
|
|
|
|
jest
|
|
.spyOn(instanceCommandRunnerService['logger'], 'log')
|
|
.mockImplementation();
|
|
jest
|
|
.spyOn(instanceCommandRunnerService['logger'], 'error')
|
|
.mockImplementation();
|
|
|
|
const workspaceCommandRunnerService = module.get(
|
|
WorkspaceCommandRunnerService,
|
|
);
|
|
|
|
jest
|
|
.spyOn(workspaceCommandRunnerService['logger'], 'log')
|
|
.mockImplementation();
|
|
jest
|
|
.spyOn(workspaceCommandRunnerService['logger'], 'error')
|
|
.mockImplementation();
|
|
|
|
return {
|
|
module,
|
|
dataSource,
|
|
runner,
|
|
};
|
|
};
|
|
|
|
let seedSequenceCounter = 0;
|
|
|
|
export const resetSeedSequenceCounter = () => {
|
|
seedSequenceCounter = 0;
|
|
};
|
|
|
|
export const seedInstanceMigration = async (
|
|
dataSource: DataSource,
|
|
{
|
|
name,
|
|
status,
|
|
workspaceIds = [],
|
|
attempt = 1,
|
|
}: {
|
|
name: string;
|
|
status: 'completed' | 'failed';
|
|
workspaceIds?: string[];
|
|
attempt?: number;
|
|
},
|
|
) => {
|
|
// Seeds must have past timestamps so the runner's NOW()-based records
|
|
// always sort after them in createdAt order.
|
|
const createdAt = new Date(
|
|
Date.now() - (1000000 - seedSequenceCounter * 1000),
|
|
).toISOString();
|
|
|
|
seedSequenceCounter++;
|
|
|
|
const values: string[] = [];
|
|
const args: unknown[] = [];
|
|
let paramIndex = 1;
|
|
|
|
values.push(
|
|
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, NULL, $${paramIndex++}, false)`,
|
|
);
|
|
args.push(name, status, attempt, EXECUTED_BY_VERSION, createdAt);
|
|
|
|
for (const workspaceId of workspaceIds) {
|
|
values.push(
|
|
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, false)`,
|
|
);
|
|
args.push(
|
|
name,
|
|
status,
|
|
attempt,
|
|
EXECUTED_BY_VERSION,
|
|
workspaceId,
|
|
createdAt,
|
|
);
|
|
}
|
|
|
|
await dataSource.query(
|
|
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "createdAt", "isInitial")
|
|
VALUES ${values.join(', ')}`,
|
|
args,
|
|
);
|
|
};
|
|
|
|
export const seedWorkspaceMigration = async (
|
|
dataSource: DataSource,
|
|
{
|
|
name,
|
|
status,
|
|
workspaceId,
|
|
attempt = 1,
|
|
isInitial = false,
|
|
useCurrentTimestamp = false,
|
|
}: {
|
|
name: string;
|
|
status: 'completed' | 'failed';
|
|
workspaceId: string;
|
|
attempt?: number;
|
|
isInitial?: boolean;
|
|
useCurrentTimestamp?: boolean;
|
|
},
|
|
) => {
|
|
if (useCurrentTimestamp) {
|
|
await dataSource.query(
|
|
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "isInitial")
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[name, status, attempt, EXECUTED_BY_VERSION, workspaceId, isInitial],
|
|
);
|
|
} else {
|
|
const createdAt = new Date(
|
|
Date.now() - (1000000 - seedSequenceCounter * 1000),
|
|
).toISOString();
|
|
|
|
seedSequenceCounter++;
|
|
|
|
await dataSource.query(
|
|
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "createdAt", "isInitial")
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
[
|
|
name,
|
|
status,
|
|
attempt,
|
|
EXECUTED_BY_VERSION,
|
|
workspaceId,
|
|
createdAt,
|
|
isInitial,
|
|
],
|
|
);
|
|
}
|
|
};
|
|
|
|
export const snapshotUpgradeMigrations = async (
|
|
dataSource: DataSource,
|
|
): Promise<UpgradeMigrationEntity[]> =>
|
|
dataSource.query(
|
|
`SELECT id, name, status, attempt, "executedByVersion", "errorMessage", "isInitial", "workspaceId", "createdAt"
|
|
FROM core."upgradeMigration"`,
|
|
);
|
|
|
|
export const restoreUpgradeMigrations = async (
|
|
dataSource: DataSource,
|
|
rows: UpgradeMigrationEntity[],
|
|
): Promise<void> => {
|
|
await dataSource.query('DELETE FROM core."upgradeMigration"');
|
|
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const columnsPerRow = 9;
|
|
const valueGroups: string[] = [];
|
|
const args: unknown[] = [];
|
|
let paramIndex = 1;
|
|
|
|
for (const row of rows) {
|
|
const placeholders = Array.from(
|
|
{ length: columnsPerRow },
|
|
() => `$${paramIndex++}`,
|
|
);
|
|
|
|
valueGroups.push(`(${placeholders.join(', ')})`);
|
|
args.push(
|
|
row.id,
|
|
row.name,
|
|
row.status,
|
|
row.attempt,
|
|
row.executedByVersion,
|
|
row.errorMessage,
|
|
row.isInitial,
|
|
row.workspaceId,
|
|
row.createdAt,
|
|
);
|
|
}
|
|
|
|
await dataSource.query(
|
|
`INSERT INTO core."upgradeMigration"
|
|
(id, name, status, attempt, "executedByVersion", "errorMessage", "isInitial", "workspaceId", "createdAt")
|
|
VALUES ${valueGroups.join(', ')}`,
|
|
args,
|
|
);
|
|
};
|
|
|
|
export type ExecutedMigrationRecord = {
|
|
name: string;
|
|
status: string;
|
|
attempt: number;
|
|
workspaceId: string | null;
|
|
isInitial: boolean;
|
|
};
|
|
|
|
export const testGetExecutedMigrationsInOrder = async (
|
|
dataSource: DataSource,
|
|
): Promise<ExecutedMigrationRecord[]> => {
|
|
return dataSource.query(
|
|
`SELECT name, status, attempt, "workspaceId", "isInitial"
|
|
FROM core."upgradeMigration"
|
|
ORDER BY "createdAt" ASC, "workspaceId" ASC NULLS FIRST, attempt ASC`,
|
|
);
|
|
};
|
|
|
|
export const migrationRecordToKey = ({
|
|
name,
|
|
workspaceId,
|
|
status,
|
|
attempt,
|
|
isInitial,
|
|
}: ExecutedMigrationRecord): string => {
|
|
const scope = workspaceId ?? 'instance';
|
|
const initial = isInitial ? ':initial' : '';
|
|
|
|
return `${name}:${scope}:${status}:${attempt}${initial}`;
|
|
};
|