Add workspace DDL lock env var and maintenance mode UI (#19130)
## Summary - Add `WORKSPACE_SCHEMA_DDL_LOCKED` env-only boolean config variable that blocks all workspace schema DDL changes when set to `true`. This is intended for hot upgrades where logical replication cannot handle DDL changes. Enforced at two chokepoints: - `WorkspaceMigrationRunnerService.run` — blocks all metadata-driven DDL (object/field/index CRUD, app sync/uninstall, standard app sync, upgrade commands) - `WorkspaceDataSourceService.createWorkspaceDBSchema` / `deleteWorkspaceDBSchema` — blocks workspace creation (sign-up) and hard deletion. Uses a dedicated `WorkspaceDataSourceException` (not ForbiddenException) - Add maintenance mode feature with Admin Panel UI and user-facing banner: - **Backend**: `MaintenanceModeService` stores maintenance window (startAt, endAt, optional link) in `core.keyValuePair` as `CONFIG_VARIABLE`. Validates endAt > startAt. Uses `GraphQLISODateTime` scalar for date fields. Exposed via `clientConfig` REST endpoint and admin GraphQL mutations (`setMaintenanceMode`, `clearMaintenanceMode`) - **Admin Panel**: New "Maintenance Mode" section in Health tab with UTC datetime pickers and activate/deactivate controls - **Banner**: `InformationBannerMaintenance` displayed at the top of `DefaultLayout` for all users, using Temporal API for timezone-aware formatting with an optional "Learn more" link These two features are **independent** — the DDL lock is controlled via env var for operational use, while maintenance mode is a UI notification mechanism controlled from the admin panel.
This commit is contained in:
+39
@@ -0,0 +1,39 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable, CustomError } from 'twenty-shared/utils';
|
||||
|
||||
export const WorkspaceDataSourceExceptionCode = {
|
||||
DDL_LOCKED: 'DDL_LOCKED',
|
||||
} as const;
|
||||
|
||||
const getWorkspaceDataSourceExceptionUserFriendlyMessage = (
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case WorkspaceDataSourceExceptionCode.DDL_LOCKED:
|
||||
return msg`Workspace schema changes are temporarily locked.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class WorkspaceDataSourceException extends CustomError {
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode;
|
||||
userFriendlyMessage: MessageDescriptor;
|
||||
|
||||
constructor({
|
||||
message,
|
||||
code,
|
||||
userFriendlyMessage,
|
||||
}: {
|
||||
message: string;
|
||||
code: keyof typeof WorkspaceDataSourceExceptionCode;
|
||||
userFriendlyMessage?: MessageDescriptor;
|
||||
}) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.userFriendlyMessage =
|
||||
userFriendlyMessage ??
|
||||
getWorkspaceDataSourceExceptionUserFriendlyMessage(code);
|
||||
}
|
||||
}
|
||||
+20
@@ -7,12 +7,17 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { type DataSource, type EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import {
|
||||
WorkspaceDataSourceException,
|
||||
WorkspaceDataSourceExceptionCode,
|
||||
} from 'src/engine/workspace-datasource/exceptions/workspace-datasource.exception';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
|
||||
@Injectable()
|
||||
@@ -24,8 +29,19 @@ export class WorkspaceDataSourceService {
|
||||
private readonly coreDataSource: DataSource,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
private assertDDLNotLocked(): void {
|
||||
if (this.twentyConfigService.get('WORKSPACE_SCHEMA_DDL_LOCKED')) {
|
||||
throw new WorkspaceDataSourceException({
|
||||
message:
|
||||
'Workspace schema DDL changes are locked. This is typically set during hot upgrades.',
|
||||
code: WorkspaceDataSourceExceptionCode.DDL_LOCKED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async checkSchemaExists(workspaceId: string) {
|
||||
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_DATASOURCE_MIGRATED,
|
||||
@@ -57,6 +73,8 @@ export class WorkspaceDataSourceService {
|
||||
* @returns
|
||||
*/
|
||||
public async createWorkspaceDBSchema(workspaceId: string): Promise<string> {
|
||||
this.assertDDLNotLocked();
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
@@ -77,6 +95,8 @@ export class WorkspaceDataSourceService {
|
||||
* @returns
|
||||
*/
|
||||
public async deleteWorkspaceDBSchema(workspaceId: string): Promise<void> {
|
||||
this.assertDDLNotLocked();
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user