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:
Charles Bochet
2026-04-02 12:17:04 +02:00
committed by GitHub
parent 9438b9869c
commit 81f10c586f
78 changed files with 2343 additions and 713 deletions
@@ -0,0 +1,40 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export const AdminPanelExceptionCode = appendCommonExceptionCode({
INVALID_MAINTENANCE_MODE_TIME_RANGE: 'INVALID_MAINTENANCE_MODE_TIME_RANGE',
} as const);
const getAdminPanelExceptionUserFriendlyMessage = (
code: keyof typeof AdminPanelExceptionCode,
) => {
switch (code) {
case AdminPanelExceptionCode.INVALID_MAINTENANCE_MODE_TIME_RANGE:
return msg`Please choose an end date and time after the start date and time.`;
case AdminPanelExceptionCode.INTERNAL_SERVER_ERROR:
return msg`Something went wrong. Please try again.`;
default:
assertUnreachable(code);
}
};
export class AdminPanelException extends CustomException<
keyof typeof AdminPanelExceptionCode
> {
constructor(
message: string,
code: keyof typeof AdminPanelExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getAdminPanelExceptionUserFriendlyMessage(code),
});
}
}
@@ -6,6 +6,7 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
@@ -24,7 +25,9 @@ import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-clie
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
@@ -44,18 +47,21 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
SecureHttpClientModule,
ApplicationRegistrationModule,
UsageModule,
KeyValuePairModule,
UserVarsModule,
],
providers: [
AdminPanelResolver,
AdminPanelService,
AdminPanelHealthService,
AdminPanelQueueService,
MaintenanceModeService,
DatabaseHealthIndicator,
RedisHealthIndicator,
WorkerHealthIndicator,
ConnectedAccountHealth,
AppHealthIndicator,
],
exports: [AdminPanelService],
exports: [AdminPanelService, MaintenanceModeService],
})
export class AdminPanelModule {}
@@ -10,6 +10,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
@@ -55,9 +56,11 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AdminPanelHealthServiceDataDTO } from './dtos/admin-panel-health-service-data.dto';
import { MaintenanceModeDTO } from './dtos/maintenance-mode.dto';
import { ModelsDevModelSuggestionDTO } from './dtos/models-dev-model-suggestion.dto';
import { ModelsDevProviderSuggestionDTO } from './dtos/models-dev-provider-suggestion.dto';
import { QueueMetricsDataDTO } from './dtos/queue-metrics-data.dto';
import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@@ -82,6 +85,7 @@ export class AdminPanelResolver {
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly modelsDevCatalogService: ModelsDevCatalogService,
private readonly usageAnalyticsService: UsageAnalyticsService,
private readonly maintenanceModeService: MaintenanceModeService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
@@ -551,4 +555,42 @@ export class AdminPanelResolver {
label: nameMap.get(item.key),
}));
}
@UseGuards(AdminPanelGuard)
@Query(() => MaintenanceModeDTO, { nullable: true })
async getMaintenanceMode(): Promise<MaintenanceModeDTO | null> {
const value = await this.maintenanceModeService.getMaintenanceMode();
if (!isDefined(value)) {
return null;
}
return {
startAt: new Date(value.startAt),
endAt: new Date(value.endAt),
link: value.link,
};
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async setMaintenanceMode(
@Args() { startAt, endAt, link }: SetMaintenanceModeInput,
): Promise<boolean> {
await this.maintenanceModeService.setMaintenanceMode({
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
link,
});
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async clearMaintenanceMode(): Promise<boolean> {
await this.maintenanceModeService.clearMaintenanceMode();
return true;
}
}
@@ -0,0 +1,13 @@
import { Field, GraphQLISODateTime, ObjectType } from '@nestjs/graphql';
@ObjectType('MaintenanceMode')
export class MaintenanceModeDTO {
@Field(() => GraphQLISODateTime)
startAt: Date;
@Field(() => GraphQLISODateTime)
endAt: Date;
@Field(() => String, { nullable: true })
link?: string;
}
@@ -0,0 +1,24 @@
import { ArgsType, Field, GraphQLISODateTime } from '@nestjs/graphql';
import { IsDate, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
import { Type } from 'class-transformer';
@ArgsType()
export class SetMaintenanceModeInput {
@Field(() => GraphQLISODateTime)
@IsNotEmpty()
@Type(() => Date)
@IsDate()
startAt: Date;
@Field(() => GraphQLISODateTime)
@IsNotEmpty()
@Type(() => Date)
@IsDate()
endAt: Date;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUrl({ require_tld: false, require_protocol: true })
link?: string;
}
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import {
AdminPanelException,
AdminPanelExceptionCode,
} from 'src/engine/core-modules/admin-panel/admin-panel.exception';
import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
import {
MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
type MaintenanceModeBannerKeyValueTypeMap,
} from './types/maintenance-mode-banner-key-value.type';
const MAINTENANCE_MODE_KEY = 'MAINTENANCE_MODE';
type MaintenanceModeValue = {
startAt: string;
endAt: string;
link?: string;
};
type MaintenanceModeKeyValueTypeMap = {
[MAINTENANCE_MODE_KEY]: MaintenanceModeValue;
};
@Injectable()
export class MaintenanceModeService {
constructor(
private readonly keyValuePairService: KeyValuePairService<MaintenanceModeKeyValueTypeMap>,
private readonly userVarsService: UserVarsService<MaintenanceModeBannerKeyValueTypeMap>,
) {}
private async clearMaintenanceModeBannerDismissals(): Promise<void> {
await this.userVarsService.delete({
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
});
}
async getMaintenanceMode(): Promise<MaintenanceModeValue | null> {
const maintenanceModeKeyValuePairs = await this.keyValuePairService.get({
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
key: MAINTENANCE_MODE_KEY,
});
if (maintenanceModeKeyValuePairs.length === 0) {
return null;
}
const value = (
maintenanceModeKeyValuePairs[0] as {
value?: MaintenanceModeKeyValueTypeMap[typeof MAINTENANCE_MODE_KEY];
}
)?.value;
if (
!isDefined(value) ||
!isNonEmptyString(value.startAt) ||
!isNonEmptyString(value.endAt)
) {
return null;
}
return value;
}
async setMaintenanceMode(value: MaintenanceModeValue): Promise<void> {
if (new Date(value.endAt) <= new Date(value.startAt)) {
throw new AdminPanelException(
'Maintenance mode end date must be after start date',
AdminPanelExceptionCode.INVALID_MAINTENANCE_MODE_TIME_RANGE,
);
}
await this.clearMaintenanceModeBannerDismissals();
await this.keyValuePairService.set({
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
key: MAINTENANCE_MODE_KEY,
value,
});
}
async clearMaintenanceMode(): Promise<void> {
await this.clearMaintenanceModeBannerDismissals();
await this.keyValuePairService.delete({
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
key: MAINTENANCE_MODE_KEY,
});
}
async isMaintenanceModeBannerDismissed(
userId: string,
workspaceId: string,
): Promise<boolean> {
const isDismissed = await this.userVarsService.get({
userId,
workspaceId,
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
});
return isDismissed === true;
}
async dismissMaintenanceModeBanner(
userId: string,
workspaceId: string,
): Promise<void> {
await this.userVarsService.set({
userId,
workspaceId,
key: MAINTENANCE_MODE_BANNER_DISMISSED_KEY,
value: true,
});
}
}
@@ -0,0 +1,6 @@
export const MAINTENANCE_MODE_BANNER_DISMISSED_KEY =
'MAINTENANCE_MODE_BANNER_DISMISSED';
export type MaintenanceModeBannerKeyValueTypeMap = {
[MAINTENANCE_MODE_BANNER_DISMISSED_KEY]: boolean;
};