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:
+1
@@ -99,6 +99,7 @@ describe('ClientConfigController', () => {
|
||||
allowRequestsToTwentyIcons: true,
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
isWorkspaceSchemaDDLLocked: false,
|
||||
};
|
||||
|
||||
jest
|
||||
|
||||
+24
-1
@@ -1,4 +1,9 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import {
|
||||
Field,
|
||||
GraphQLISODateTime,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
@@ -212,6 +217,18 @@ export class PublicFeatureFlag {
|
||||
metadata: PublicFeatureFlagMetadata;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfigMaintenanceMode {
|
||||
@Field(() => GraphQLISODateTime)
|
||||
startAt: Date;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
endAt: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
link?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfig {
|
||||
@Field(() => String, { nullable: true })
|
||||
@@ -294,4 +311,10 @@ export class ClientConfig {
|
||||
|
||||
@Field(() => Boolean)
|
||||
isClickHouseConfigured: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isWorkspaceSchemaDDLLocked: boolean;
|
||||
|
||||
@Field(() => ClientConfigMaintenanceMode, { nullable: true })
|
||||
maintenance?: ClientConfigMaintenanceMode;
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,14 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
|
||||
|
||||
import { ClientConfigController } from './client-config.controller';
|
||||
import { ClientConfigResolver } from './client-config.resolver';
|
||||
|
||||
import { ClientConfigService } from './services/client-config.service';
|
||||
|
||||
@Module({
|
||||
imports: [DomainServerConfigModule],
|
||||
imports: [DomainServerConfigModule, AdminPanelModule],
|
||||
controllers: [ClientConfigController],
|
||||
providers: [ClientConfigService],
|
||||
providers: [ClientConfigResolver, ClientConfigService],
|
||||
})
|
||||
export class ClientConfigModule {}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { ClientConfigResolver } from './client-config.resolver';
|
||||
|
||||
describe('ClientConfigResolver', () => {
|
||||
let resolver: ClientConfigResolver;
|
||||
let maintenanceModeService: MaintenanceModeService;
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-id',
|
||||
} as AuthContextUser;
|
||||
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-id',
|
||||
} as WorkspaceEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClientConfigResolver,
|
||||
{
|
||||
provide: MaintenanceModeService,
|
||||
useValue: {
|
||||
dismissMaintenanceModeBanner: jest.fn(),
|
||||
isMaintenanceModeBannerDismissed: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<ClientConfigResolver>(ClientConfigResolver);
|
||||
maintenanceModeService = module.get<MaintenanceModeService>(
|
||||
MaintenanceModeService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return dismissal state from maintenance mode service', async () => {
|
||||
jest
|
||||
.spyOn(maintenanceModeService, 'isMaintenanceModeBannerDismissed')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await resolver.isMaintenanceModeBannerDismissed(
|
||||
mockUser,
|
||||
mockWorkspace,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(
|
||||
maintenanceModeService.isMaintenanceModeBannerDismissed,
|
||||
).toHaveBeenCalledWith(mockUser.id, mockWorkspace.id);
|
||||
});
|
||||
|
||||
it('should persist dismissal through maintenance mode service', async () => {
|
||||
jest
|
||||
.spyOn(maintenanceModeService, 'dismissMaintenanceModeBanner')
|
||||
.mockResolvedValue();
|
||||
|
||||
const result = await resolver.dismissMaintenanceModeBanner(
|
||||
mockUser,
|
||||
mockWorkspace,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(
|
||||
maintenanceModeService.dismissMaintenanceModeBanner,
|
||||
).toHaveBeenCalledWith(mockUser.id, mockWorkspace.id);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@CoreResolver()
|
||||
export class ClientConfigResolver {
|
||||
constructor(
|
||||
private readonly maintenanceModeService: MaintenanceModeService,
|
||||
) {}
|
||||
|
||||
@Query(() => Boolean)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async isMaintenanceModeBannerDismissed(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.maintenanceModeService.isMaintenanceModeBannerDismissed(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async dismissMaintenanceModeBanner(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
await this.maintenanceModeService.dismissMaintenanceModeBanner(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+10
@@ -9,6 +9,7 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
|
||||
describe('ClientConfigService', () => {
|
||||
let service: ClientConfigService;
|
||||
@@ -39,6 +40,12 @@ describe('ClientConfigService', () => {
|
||||
getModelConfig: jest.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MaintenanceModeService,
|
||||
useValue: {
|
||||
getMaintenanceMode: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -91,6 +98,8 @@ describe('ClientConfigService', () => {
|
||||
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
|
||||
CLOUDFLARE_API_KEY: undefined,
|
||||
CLOUDFLARE_ZONE_ID: undefined,
|
||||
ALLOW_REQUESTS_TO_TWENTY_ICONS: false,
|
||||
CLICKHOUSE_URL: undefined,
|
||||
};
|
||||
|
||||
return mockValues[key];
|
||||
@@ -159,6 +168,7 @@ describe('ClientConfigService', () => {
|
||||
isGoogleCalendarEnabled: true,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
allowRequestsToTwentyIcons: false,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
|
||||
+17
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import {
|
||||
type ClientAIModelConfig,
|
||||
type ClientConfig,
|
||||
@@ -33,6 +35,7 @@ export class ClientConfigService {
|
||||
private twentyConfigService: TwentyConfigService,
|
||||
private domainServerConfigService: DomainServerConfigService,
|
||||
private aiModelRegistryService: AiModelRegistryService,
|
||||
private maintenanceModeService: MaintenanceModeService,
|
||||
) {}
|
||||
|
||||
private deriveNativeCapabilities(
|
||||
@@ -237,8 +240,22 @@ export class ClientConfigService {
|
||||
: undefined,
|
||||
isCloudflareIntegrationEnabled: this.isCloudflareIntegrationEnabled(),
|
||||
isClickHouseConfigured: !!this.twentyConfigService.get('CLICKHOUSE_URL'),
|
||||
isWorkspaceSchemaDDLLocked: this.twentyConfigService.get(
|
||||
'WORKSPACE_SCHEMA_DDL_LOCKED',
|
||||
),
|
||||
};
|
||||
|
||||
const maintenanceMode =
|
||||
await this.maintenanceModeService.getMaintenanceMode();
|
||||
|
||||
if (isDefined(maintenanceMode)) {
|
||||
clientConfig.maintenance = {
|
||||
startAt: new Date(maintenanceMode.startAt),
|
||||
endAt: new Date(maintenanceMode.endAt),
|
||||
link: maintenanceMode.link,
|
||||
};
|
||||
}
|
||||
|
||||
return clientConfig;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user