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:
Charles Bochet
2026-07-15 17:03:17 +02:00
committed by GitHub
parent cdb2590355
commit 8e03921372
116 changed files with 498 additions and 307 deletions
@@ -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(
@@ -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 =
@@ -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(
@@ -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({
@@ -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({
@@ -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];
@@ -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;
}
@@ -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) {
@@ -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;
}
@@ -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,
@@ -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;
@@ -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');
@@ -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(
@@ -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 [];
}
@@ -366,7 +366,10 @@ export class WorkspaceService {
});
if (
existingWorkspace?.activationStatus === WorkspaceActivationStatus.ACTIVE
existingWorkspace?.activationStatus ===
WorkspaceActivationStatus.ACTIVE ||
existingWorkspace?.activationStatus ===
WorkspaceActivationStatus.CREATED
) {
return existingWorkspace;
}
@@ -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,
},
);