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:
@@ -1756,7 +1756,6 @@ enum FeatureFlagKey {
|
||||
IS_JUNCTION_RELATIONS_ENABLED
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED
|
||||
IS_WORKFLOW_VERSION_IN_CORE_ENABLED
|
||||
}
|
||||
@@ -1960,6 +1959,7 @@ type ClientConfig {
|
||||
isCloudflareIntegrationEnabled: Boolean!
|
||||
isClickHouseConfigured: Boolean!
|
||||
isWorkspaceSchemaDDLLocked: Boolean!
|
||||
isOnboardingAiChatEnabled: Boolean!
|
||||
enterpriseInstanceType: String!
|
||||
maintenance: ClientConfigMaintenanceMode
|
||||
}
|
||||
|
||||
@@ -1406,7 +1406,7 @@ export interface FeatureFlag {
|
||||
__typename: 'FeatureFlag'
|
||||
}
|
||||
|
||||
export type FeatureFlagKey = 'IS_APP_CLAIMING_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_CALENDAR_WEEK_VIEW_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_ONBOARDING_AI_CHAT_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED'
|
||||
export type FeatureFlagKey = 'IS_APP_CLAIMING_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_CALENDAR_WEEK_VIEW_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED'
|
||||
|
||||
export interface WorkspaceUrls {
|
||||
customUrl?: Scalars['String']
|
||||
@@ -1608,6 +1608,7 @@ export interface ClientConfig {
|
||||
isCloudflareIntegrationEnabled: Scalars['Boolean']
|
||||
isClickHouseConfigured: Scalars['Boolean']
|
||||
isWorkspaceSchemaDDLLocked: Scalars['Boolean']
|
||||
isOnboardingAiChatEnabled: Scalars['Boolean']
|
||||
enterpriseInstanceType: Scalars['String']
|
||||
maintenance?: ClientConfigMaintenanceMode
|
||||
__typename: 'ClientConfig'
|
||||
@@ -4786,6 +4787,7 @@ export interface ClientConfigGenqlSelection{
|
||||
isCloudflareIntegrationEnabled?: boolean | number
|
||||
isClickHouseConfigured?: boolean | number
|
||||
isWorkspaceSchemaDDLLocked?: boolean | number
|
||||
isOnboardingAiChatEnabled?: boolean | number
|
||||
enterpriseInstanceType?: boolean | number
|
||||
maintenance?: ClientConfigMaintenanceModeGenqlSelection
|
||||
__typename?: boolean | number
|
||||
@@ -9416,7 +9418,6 @@ export const enumFeatureFlagKey = {
|
||||
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const,
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: 'IS_ONBOARDING_AI_CHAT_ENABLED' as const,
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const,
|
||||
IS_WORKFLOW_VERSION_IN_CORE_ENABLED: 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED' as const
|
||||
}
|
||||
|
||||
@@ -3802,6 +3802,9 @@ export default {
|
||||
"isWorkspaceSchemaDDLLocked": [
|
||||
3
|
||||
],
|
||||
"isOnboardingAiChatEnabled": [
|
||||
3
|
||||
],
|
||||
"enterpriseInstanceType": [
|
||||
1
|
||||
],
|
||||
|
||||
@@ -326,7 +326,6 @@ export enum FeatureFlagKey {
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
|
||||
@@ -956,6 +956,7 @@ export type ClientConfig = {
|
||||
isMicrosoftCalendarEnabled: Scalars['Boolean']['output'];
|
||||
isMicrosoftMessagingEnabled: Scalars['Boolean']['output'];
|
||||
isMultiWorkspaceEnabled: Scalars['Boolean']['output'];
|
||||
isOnboardingAiChatEnabled: Scalars['Boolean']['output'];
|
||||
isWorkspaceSchemaDDLLocked: Scalars['Boolean']['output'];
|
||||
maintenance?: Maybe<ClientConfigMaintenanceMode>;
|
||||
publicFeatureFlags: Array<PublicFeatureFlag>;
|
||||
@@ -1772,7 +1773,6 @@ export enum FeatureFlagKey {
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
|
||||
@@ -24,6 +24,7 @@ import { maintenanceModeState } from '@/client-config/states/maintenanceModeStat
|
||||
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
|
||||
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
|
||||
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
|
||||
import { supportChatState } from '@/client-config/states/supportChatState';
|
||||
@@ -126,6 +127,10 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
|
||||
const setIsDDLLocked = useSetAtomState(isDDLLockedState);
|
||||
|
||||
const setIsOnboardingAiChatEnabled = useSetAtomState(
|
||||
isOnboardingAiChatEnabledState,
|
||||
);
|
||||
|
||||
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
|
||||
|
||||
const setEnterpriseInstanceType = useSetAtomState(
|
||||
@@ -215,6 +220,9 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
);
|
||||
setIsClickHouseConfigured(clientConfig?.isClickHouseConfigured ?? false);
|
||||
setIsDDLLocked(clientConfig?.isWorkspaceSchemaDDLLocked ?? false);
|
||||
setIsOnboardingAiChatEnabled(
|
||||
clientConfig?.isOnboardingAiChatEnabled ?? false,
|
||||
);
|
||||
setMaintenanceMode(clientConfig?.maintenance ?? null);
|
||||
setEnterpriseInstanceType(
|
||||
clientConfig?.enterpriseInstanceType ??
|
||||
@@ -256,6 +264,7 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsClickHouseConfigured,
|
||||
setIsCloudflareIntegrationEnabled,
|
||||
setIsDDLLocked,
|
||||
setIsOnboardingAiChatEnabled,
|
||||
setLabPublicFeatureFlags,
|
||||
setMaintenanceMode,
|
||||
setEnterpriseInstanceType,
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isOnboardingAiChatEnabledState = createAtomState<boolean>({
|
||||
key: 'isOnboardingAiChatEnabledState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export type ClientConfig = {
|
||||
isCloudflareIntegrationEnabled: boolean;
|
||||
isClickHouseConfigured: boolean;
|
||||
isWorkspaceSchemaDDLLocked: boolean;
|
||||
isOnboardingAiChatEnabled: boolean;
|
||||
onboarding: OnboardingConfig;
|
||||
publicFeatureFlags: Array<PublicFeatureFlag>;
|
||||
sentry: Sentry;
|
||||
|
||||
+4
-4
@@ -3,14 +3,14 @@ import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import {
|
||||
EnrichWorkspaceCompanyDocument,
|
||||
FeatureFlagKey,
|
||||
OnboardingStatus,
|
||||
WorkspaceCompanyEnrichmentOutcome,
|
||||
} from '~/generated-metadata/graphql';
|
||||
@@ -25,8 +25,8 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
setHasAttemptedCompanyEnrichmentFetch,
|
||||
] = useAtomState(hasAttemptedCompanyEnrichmentFetchState);
|
||||
const [enrichWorkspaceCompany] = useMutation(EnrichWorkspaceCompanyDocument);
|
||||
const isOnboardingAiChatEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
const isOnboardingAiChatEnabled = useAtomStateValue(
|
||||
isOnboardingAiChatEnabledState,
|
||||
);
|
||||
|
||||
const isOnboardingInProgress =
|
||||
|
||||
+3
-7
@@ -4,6 +4,7 @@ import { GraphQLError } from 'graphql';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { CompanyEnrichmentOnboardingEffect } from '@/onboarding/effect-components/CompanyEnrichmentOnboardingEffect';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
|
||||
@@ -17,16 +18,11 @@ import {
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const mockOnboardingStatus = jest.fn();
|
||||
const mockIsOnboardingAiChatEnabled = jest.fn();
|
||||
|
||||
jest.mock('@/onboarding/hooks/useOnboardingStatus', () => ({
|
||||
useOnboardingStatus: () => mockOnboardingStatus(),
|
||||
}));
|
||||
|
||||
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
|
||||
useIsFeatureEnabled: () => mockIsOnboardingAiChatEnabled(),
|
||||
}));
|
||||
|
||||
const enrichment: WorkspaceCompanyEnrichment = {
|
||||
domain: 'acme.com',
|
||||
enrichedAt: '2026-07-21T10:00:00.000Z',
|
||||
@@ -89,7 +85,7 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
|
||||
resetJotaiStore();
|
||||
localStorage.clear();
|
||||
mockOnboardingStatus.mockReturnValue(OnboardingStatus.PROFILE_CREATION);
|
||||
mockIsOnboardingAiChatEnabled.mockReturnValue(true);
|
||||
jotaiStore.set(isOnboardingAiChatEnabledState.atom, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -156,7 +152,7 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
|
||||
);
|
||||
|
||||
it('does not fetch when onboarding AI chat is disabled', async () => {
|
||||
mockIsOnboardingAiChatEnabled.mockReturnValue(false);
|
||||
jotaiStore.set(isOnboardingAiChatEnabledState.atom, false);
|
||||
|
||||
let callCount = 0;
|
||||
renderEffect([
|
||||
|
||||
+9
-9
@@ -6,6 +6,7 @@ import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
mockCurrentWorkspace,
|
||||
mockedUserData,
|
||||
@@ -42,6 +43,11 @@ const renderHooks = (
|
||||
isOnboardingAiChatEnabled = false,
|
||||
}: RenderHooksOptions = {},
|
||||
) => {
|
||||
jotaiStore.set(
|
||||
isOnboardingAiChatEnabledState.atom,
|
||||
isOnboardingAiChatEnabled,
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const [currentUser, setCurrentUser] = useAtomState(currentUserState);
|
||||
@@ -81,12 +87,6 @@ const renderHooks = (
|
||||
? mockCurrentWorkspace.billingSubscriptions
|
||||
: [],
|
||||
workspaceMembersCount: withOneWorkspaceMember ? 1 : 2,
|
||||
featureFlags: [
|
||||
{
|
||||
key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
value: isOnboardingAiChatEnabled,
|
||||
},
|
||||
],
|
||||
});
|
||||
result.current.setBilling({
|
||||
__typename: 'Billing',
|
||||
@@ -257,7 +257,7 @@ describe('useSetNextOnboardingStatus', () => {
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should open the ai chat after onboarding when the feature flag is enabled', () => {
|
||||
it('should open the ai chat after onboarding when it is enabled', () => {
|
||||
const { isWelcomeAnimationVisible, shouldOpenAiChatAfterOnboarding } =
|
||||
renderHooks(OnboardingStatus.INVITE_TEAM, {
|
||||
isOnboardingAiChatEnabled: true,
|
||||
@@ -266,7 +266,7 @@ describe('useSetNextOnboardingStatus', () => {
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
it('should still show the welcome animation when the ai chat feature flag is disabled', () => {
|
||||
it('should still show the welcome animation when the ai chat is disabled', () => {
|
||||
const { isWelcomeAnimationVisible, shouldOpenAiChatAfterOnboarding } =
|
||||
renderHooks(OnboardingStatus.INVITE_TEAM, {
|
||||
isOnboardingAiChatEnabled: false,
|
||||
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
currentWorkspaceState,
|
||||
} from '@/auth/states/currentWorkspaceState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { getHasJustCompletedOnboarding } from '@/onboarding/utils/getHasJustCompletedOnboarding';
|
||||
import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
type GetNextOnboardingStatusArgs = {
|
||||
@@ -73,8 +73,8 @@ export const useSetNextOnboardingStatus = () => {
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const isBillingEnabled = billing?.isBillingEnabled ?? false;
|
||||
const isOnboardingAiChatEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
const isOnboardingAiChatEnabled = useAtomStateValue(
|
||||
isOnboardingAiChatEnabledState,
|
||||
);
|
||||
|
||||
return useCallback(() => {
|
||||
|
||||
+4
-6
@@ -1,11 +1,10 @@
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { checkIfFeatureFlagIsEnabledOnWorkspace } from '@/workspace/utils/checkIfFeatureFlagIsEnabledOnWorkspace';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { isOnboardingCheckoutPendingState } from '@/onboarding/states/isOnboardingCheckoutPendingState';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useStore } from 'jotai';
|
||||
import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useShowWelcomeAnimationAfterOnboardingCheckout = () => {
|
||||
const store = useStore();
|
||||
@@ -21,9 +20,8 @@ export const useShowWelcomeAnimationAfterOnboardingCheckout = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOnboardingAiChatEnabled = checkIfFeatureFlagIsEnabledOnWorkspace(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
store.get(currentWorkspaceState.atom),
|
||||
const isOnboardingAiChatEnabled = store.get(
|
||||
isOnboardingAiChatEnabledState.atom,
|
||||
);
|
||||
|
||||
store.set(isOnboardingCheckoutPendingState.atom, false);
|
||||
|
||||
+3
-4
@@ -6,11 +6,10 @@ import { IconButton } from 'twenty-ui/input';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
export const SidePanelExpandAiChatButton = () => {
|
||||
const { t } = useLingui();
|
||||
@@ -18,8 +17,8 @@ export const SidePanelExpandAiChatButton = () => {
|
||||
const location = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const sidePanelPage = useAtomStateValue(sidePanelPageState);
|
||||
const isOnboardingAiChatEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
const isOnboardingAiChatEnabled = useAtomStateValue(
|
||||
isOnboardingAiChatEnabledState,
|
||||
);
|
||||
const setAiChatExpandedReturnLocation = useSetAtomState(
|
||||
aiChatExpandedReturnLocationState,
|
||||
|
||||
@@ -5,13 +5,12 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
|
||||
import { AiChatTab } from '@/ai/components/AiChatTab';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble';
|
||||
import { WorkspaceSetupHeader } from '@/onboarding/components/WorkspaceSetupHeader';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
const PANEL_CORNER_RADIUS_DERIVED_FROM_THEME_SCALE = `calc(${themeCssVariables.border.radius.md} + ${themeCssVariables.spacing[1]})`;
|
||||
|
||||
@@ -39,8 +38,8 @@ const StyledContent = styled.div`
|
||||
export const WorkspaceSetup = () => {
|
||||
const { t } = useLingui();
|
||||
const { defaultHomePagePath } = useDefaultHomePagePath();
|
||||
const isOnboardingAiChatEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
const isOnboardingAiChatEnabled = useAtomStateValue(
|
||||
isOnboardingAiChatEnabledState,
|
||||
);
|
||||
const shouldOpenAiChatAfterOnboarding = useAtomStateValue(
|
||||
shouldOpenAiChatAfterOnboardingState,
|
||||
|
||||
@@ -5,16 +5,14 @@ import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
import { messages } from '~/locales/generated/en';
|
||||
import { WorkspaceSetup } from '~/pages/onboarding/WorkspaceSetup';
|
||||
import { mockCurrentWorkspace } from '~/testing/mock-data/users';
|
||||
|
||||
i18n.load({ [SOURCE_LOCALE]: messages });
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
@@ -57,13 +55,8 @@ jest.mock('react-router-dom', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const setOnboardingAiChatFeatureFlag = (value: boolean) => {
|
||||
jotaiStore.set(currentWorkspaceState.atom, {
|
||||
...mockCurrentWorkspace,
|
||||
featureFlags: [
|
||||
{ key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, value },
|
||||
],
|
||||
});
|
||||
const setIsOnboardingAiChatEnabled = (value: boolean) => {
|
||||
jotaiStore.set(isOnboardingAiChatEnabledState.atom, value);
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
@@ -80,7 +73,7 @@ describe('WorkspaceSetup', () => {
|
||||
});
|
||||
|
||||
it('should dress the chat for onboarding when the post-onboarding hint is set', () => {
|
||||
setOnboardingAiChatFeatureFlag(true);
|
||||
setIsOnboardingAiChatEnabled(true);
|
||||
jotaiStore.set(shouldOpenAiChatAfterOnboardingState.atom, true);
|
||||
|
||||
const { getByTestId } = render(<WorkspaceSetup />, { wrapper: Wrapper });
|
||||
@@ -91,7 +84,7 @@ describe('WorkspaceSetup', () => {
|
||||
});
|
||||
|
||||
it('should render a plain chat when the post-onboarding hint is not set', () => {
|
||||
setOnboardingAiChatFeatureFlag(true);
|
||||
setIsOnboardingAiChatEnabled(true);
|
||||
|
||||
const { getByTestId, queryByTestId } = render(<WorkspaceSetup />, {
|
||||
wrapper: Wrapper,
|
||||
@@ -102,8 +95,8 @@ describe('WorkspaceSetup', () => {
|
||||
expect(getByTestId('header-title')).toHaveTextContent('Ask AI');
|
||||
});
|
||||
|
||||
it('should redirect home when the onboarding ai chat feature flag is disabled', () => {
|
||||
setOnboardingAiChatFeatureFlag(false);
|
||||
it('should redirect home when the onboarding ai chat is disabled', () => {
|
||||
setIsOnboardingAiChatEnabled(false);
|
||||
|
||||
const { queryByTestId } = render(<WorkspaceSetup />, { wrapper: Wrapper });
|
||||
|
||||
|
||||
@@ -66,4 +66,5 @@ export const mockedClientConfig: ClientConfig = {
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
isWorkspaceSchemaDDLLocked: false,
|
||||
isOnboardingAiChatEnabled: false,
|
||||
};
|
||||
|
||||
@@ -86,10 +86,11 @@ FRONTEND_URL=http://localhost:3001
|
||||
# HTTP_TOOL_SAFE_MODE_ENABLED=true
|
||||
# ALLOW_REQUESTS_TO_TWENTY_ICONS=true
|
||||
|
||||
# ———————— ENRICHMENT ————————
|
||||
# Enriches a new workspace with its own company data, used as AI chat context.
|
||||
# Disabled by default; requires both the flag and the API key.
|
||||
# IS_WORKSPACE_COMPANY_ENRICHMENT_ENABLED=true
|
||||
# ———————— ONBOARDING AI CHAT ————————
|
||||
# Opens an AI chat at the end of onboarding to help set up the workspace.
|
||||
# IS_ONBOARDING_AI_CHAT_ENABLED=true
|
||||
# Enriches the new workspace with its own company data, used as chat context.
|
||||
# Active as soon as the API key is set.
|
||||
# PEOPLE_DATA_LABS_API_KEY=
|
||||
|
||||
# ———————— AI ————————
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
+6
-32
@@ -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(
|
||||
|
||||
+1
-14
@@ -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,
|
||||
|
||||
-1
@@ -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: {},
|
||||
|
||||
-5
@@ -50,11 +50,6 @@ export const seedFeatureFlags = async ({
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ export enum FeatureFlagKey {
|
||||
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_WORKFLOW_VERSION_IN_CORE_ENABLED = 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user