Replace the onboarding AI chat feature flag with an environment variable (#23439)

Follow-up to #23199.

The AI-chat onboarding is an instance-level rollout decision, not a
per-workspace experiment, so `IS_ONBOARDING_AI_CHAT_ENABLED` becomes an
instance config variable (default `false`, editable from the admin
panel) exposed to the frontend through `ClientConfig`. The workspace
feature flag is deleted; leftover `featureFlag` rows are inert since the
column is plain text.

`IS_WORKSPACE_COMPANY_ENRICHMENT_ENABLED` is removed as redundant: the
PDL client already skips everything when no API key is set. Enrichment
now runs when the AI chat is on and `PEOPLE_DATA_LABS_API_KEY` is
configured.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23439?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-28 16:46:52 +02:00
committed by GitHub
parent 884f470982
commit 9509c737e0
30 changed files with 85 additions and 122 deletions
@@ -109,6 +109,7 @@ describe('ClientConfigController', () => {
isCloudflareIntegrationEnabled: false,
isClickHouseConfigured: false,
isWorkspaceSchemaDDLLocked: false,
isOnboardingAiChatEnabled: false,
enterpriseInstanceType: ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
};
@@ -345,6 +345,9 @@ export class ClientConfig {
@Field(() => Boolean)
isWorkspaceSchemaDDLLocked: boolean;
@Field(() => Boolean)
isOnboardingAiChatEnabled: boolean;
@Field(() => String)
enterpriseInstanceType: string;
@@ -110,6 +110,7 @@ describe('ClientConfigService', () => {
CLOUDFLARE_ZONE_ID: undefined,
ALLOW_REQUESTS_TO_TWENTY_ICONS: false,
CLICKHOUSE_URL: undefined,
IS_ONBOARDING_AI_CHAT_ENABLED: false,
};
return mockValues[key];
@@ -191,6 +192,7 @@ describe('ClientConfigService', () => {
calendarBookingPageId: 'team/twenty/talk-to-us',
isCloudflareIntegrationEnabled: false,
isClickHouseConfigured: false,
isOnboardingAiChatEnabled: false,
enterpriseInstanceType: ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
});
});
@@ -281,6 +281,9 @@ export class ClientConfigService {
isWorkspaceSchemaDDLLocked: this.twentyConfigService.get(
'WORKSPACE_SCHEMA_DDL_LOCKED',
),
isOnboardingAiChatEnabled: this.twentyConfigService.get(
'IS_ONBOARDING_AI_CHAT_ENABLED',
),
enterpriseInstanceType:
this.twentyConfigService.get('ENTERPRISE_INSTANCE_TYPE') ??
ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
@@ -4,7 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CompanyEnrichmentResolver } from 'src/engine/core-modules/company-enrichment/resolvers/company-enrichment.resolver';
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
import { PeopleDataLabsCompanyClientService } from 'src/engine/core-modules/company-enrichment/services/people-data-labs-company-client.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
@@ -13,7 +12,6 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user
@Module({
imports: [
TypeOrmModule.forFeature([UserWorkspaceEntity]),
FeatureFlagModule,
KeyValuePairModule,
SecureHttpClientModule,
ThrottlerModule,
@@ -3,7 +3,6 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
import { PeopleDataLabsCompanyClientService } from 'src/engine/core-modules/company-enrichment/services/people-data-labs-company-client.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { COMPANY_ENRICHMENT_ATTEMPT_KEY } from 'src/engine/core-modules/company-enrichment/types/company-enrichment-attempt-key-value.type';
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';
@@ -24,8 +23,7 @@ describe('CompanyEnrichmentService', () => {
};
let throttlerService: { tokenBucketThrottleOrThrow: jest.Mock };
let keyValuePairService: { set: jest.Mock };
let twentyConfigService: { isWorkspaceCompanyEnrichmentEnabled: jest.Mock };
let featureFlagService: { isFeatureEnabled: jest.Mock };
let twentyConfigService: { get: jest.Mock };
const workspaceId = 'workspace-id';
const creatorUserId = 'creator-user-id';
@@ -41,10 +39,7 @@ describe('CompanyEnrichmentService', () => {
throttlerService = { tokenBucketThrottleOrThrow: jest.fn() };
keyValuePairService = { set: jest.fn() };
twentyConfigService = {
isWorkspaceCompanyEnrichmentEnabled: jest.fn().mockReturnValue(true),
};
featureFlagService = {
isFeatureEnabled: jest.fn().mockResolvedValue(true),
get: jest.fn().mockReturnValue(true),
};
const module: TestingModule = await Test.createTestingModule({
@@ -66,10 +61,6 @@ describe('CompanyEnrichmentService', () => {
provide: TwentyConfigService,
useValue: twentyConfigService,
},
{
provide: FeatureFlagService,
useValue: featureFlagService,
},
{
provide: KeyValuePairService,
useValue: keyValuePairService,
@@ -210,8 +201,8 @@ describe('CompanyEnrichmentService', () => {
);
});
it('should return unavailable without enriching when onboarding AI chat is off', async () => {
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
it('should return unavailable without any lookup when onboarding AI chat is off', async () => {
twentyConfigService.get.mockReturnValue(false);
const result = await service.enrichCompanyForWorkspaceCreator({
userId: creatorUserId,
@@ -220,26 +211,9 @@ describe('CompanyEnrichmentService', () => {
});
expect(result).toEqual({ outcome: 'unavailable', enrichment: null });
expect(userWorkspaceRepository.findOne).not.toHaveBeenCalled();
expect(throttlerService.tokenBucketThrottleOrThrow).not.toHaveBeenCalled();
expect(
peopleDataLabsCompanyClientService.enrichCompanyByDomain,
).not.toHaveBeenCalled();
expect(keyValuePairService.set).not.toHaveBeenCalled();
});
it('should return unavailable without any lookup when the enrichment flag is off', async () => {
twentyConfigService.isWorkspaceCompanyEnrichmentEnabled.mockReturnValue(
false,
expect(twentyConfigService.get).toHaveBeenCalledWith(
'IS_ONBOARDING_AI_CHAT_ENABLED',
);
const result = await service.enrichCompanyForWorkspaceCreator({
userId: creatorUserId,
email: 'foo@acme.com',
workspaceId,
});
expect(result).toEqual({ outcome: 'unavailable', enrichment: null });
expect(userWorkspaceRepository.findOne).not.toHaveBeenCalled();
expect(throttlerService.tokenBucketThrottleOrThrow).not.toHaveBeenCalled();
expect(
@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceCompanyEnrichmentResult } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
@@ -10,7 +9,6 @@ import { Repository } from 'typeorm';
import { COMPANY_ENRICHMENT_THROTTLE_MAX_REQUESTS } from 'src/engine/core-modules/company-enrichment/constants/company-enrichment-throttle-max-requests.constant';
import { COMPANY_ENRICHMENT_THROTTLE_WINDOW_MS } from 'src/engine/core-modules/company-enrichment/constants/company-enrichment-throttle-window-ms.constant';
import { PeopleDataLabsCompanyClientService } from 'src/engine/core-modules/company-enrichment/services/people-data-labs-company-client.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
COMPANY_ENRICHMENT_ATTEMPT_KEY,
type CompanyEnrichmentAttemptKeyValueTypeMap,
@@ -38,7 +36,6 @@ export class CompanyEnrichmentService {
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly peopleDataLabsCompanyClientService: PeopleDataLabsCompanyClientService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
private readonly throttlerService: ThrottlerService,
private readonly keyValuePairService: KeyValuePairService<CompanyEnrichmentAttemptKeyValueTypeMap>,
) {}
@@ -52,18 +49,8 @@ export class CompanyEnrichmentService {
email: string;
workspaceId: string;
}): Promise<WorkspaceCompanyEnrichmentResult> {
if (!this.twentyConfigService.isWorkspaceCompanyEnrichmentEnabled()) {
return { outcome: 'unavailable', enrichment: null };
}
// The enrichment only feeds the AI-chat workspace setup, so it is pointless without it.
const isOnboardingAiChatEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
workspaceId,
);
if (!isOnboardingAiChatEnabled) {
if (!this.twentyConfigService.get('IS_ONBOARDING_AI_CHAT_ENABLED')) {
return { outcome: 'unavailable', enrichment: null };
}
@@ -1953,11 +1953,11 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description:
'Enable or disable workspace company enrichment during onboarding',
'Enable or disable the AI chat that helps set up the workspace at the end of onboarding',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_WORKSPACE_COMPANY_ENRICHMENT_ENABLED = false;
IS_ONBOARDING_AI_CHAT_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
@@ -205,10 +205,6 @@ export class TwentyConfigService {
return this.get('IS_BILLING_ENABLED') === true;
}
isWorkspaceCompanyEnrichmentEnabled(): boolean {
return this.get('IS_WORKSPACE_COMPANY_ENRICHMENT_ENABLED') === true;
}
private validateNotEnvOnly<T extends keyof ConfigVariables>(
key: T,
operation: string,
@@ -247,7 +247,6 @@ describe('WorkspaceEntityManager', () => {
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: false,
IS_SETTINGS_DISCOVERY_HERO_ENABLED: false,
IS_WORKFLOW_VERSION_IN_CORE_ENABLED: false,
IS_ONBOARDING_AI_CHAT_ENABLED: false,
},
userWorkspaceRoleMap: {},
apiKeyRoleMap: {},
@@ -50,11 +50,6 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};