From 30dd457313d3e167fd2f4817e052ac51071f907e Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Thu, 24 Jul 2025 01:37:02 +0530 Subject: [PATCH] App version mismatch handling between frontend and backend (#13368) https://github.com/user-attachments/assets/d153f177-4d70-4ec6-8693-15413e550938 --- .../hooks/__tests__/useApolloFactory.test.tsx | 5 +- .../modules/apollo/hooks/useApolloFactory.ts | 23 +++++++++- .../services/__tests__/apollo.factory.test.ts | 1 + .../modules/apollo/services/apollo.factory.ts | 19 ++++++++ .../components/ClientConfigProviderEffect.tsx | 6 ++- .../client-config/states/appVersionState.ts | 6 +++ .../client-config/types/ClientConfig.ts | 1 + .../graphql-config/graphql-config.service.ts | 1 + .../api/graphql/metadata.module-factory.ts | 1 + .../client-config/client-config.entity.ts | 3 ++ .../services/client-config.service.spec.ts | 1 + .../services/client-config.service.ts | 1 + .../hooks/use-graphql-error-handler.hook.ts | 46 +++++++++++++++++++ .../metrics/types/metrics-keys.type.ts | 2 + 14 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-front/src/modules/client-config/states/appVersionState.ts diff --git a/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx b/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx index 5e19a8309..d5a4c2b20 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx +++ b/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx @@ -4,6 +4,7 @@ import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; import { MemoryRouter, useLocation } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; +import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider'; import { useApolloFactory } from '../useApolloFactory'; enableFetchMocks(); @@ -25,7 +26,9 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => ( initialEntries={['/welcome', '/verify', '/opportunities']} initialIndex={2} > - {children} + + {children} + ); diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index 55c71bffb..861b4d032 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -1,7 +1,7 @@ import { InMemoryCache, NormalizedCacheObject } from '@apollo/client'; import { useMemo, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useRecoilState, useSetRecoilState } from 'recoil'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; @@ -13,7 +13,9 @@ import { useUpdateEffect } from '~/hooks/useUpdateEffect'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { appVersionState } from '@/client-config/states/appVersionState'; import { AppPath } from '@/types/AppPath'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { isDefined } from 'twenty-shared/utils'; import { ApolloFactory, Options } from '../services/apollo.factory'; @@ -26,6 +28,7 @@ export const useApolloFactory = (options: Partial> = {}) => { const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, ); + const appVersion = useRecoilValue(appVersionState); const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState( currentWorkspaceMemberState, ); @@ -35,6 +38,8 @@ export const useApolloFactory = (options: Partial> = {}) => { const setPreviousUrl = useSetRecoilState(previousUrlState); const location = useLocation(); + const { enqueueErrorSnackBar } = useSnackBar(); + const apolloClient = useMemo(() => { apolloRef.current = new ApolloFactory({ uri: `${REACT_APP_SERVER_BASE_URL}/graphql`, @@ -54,6 +59,7 @@ export const useApolloFactory = (options: Partial> = {}) => { connectToDevTools: process.env.IS_DEBUG_MODE === 'true', currentWorkspaceMember: currentWorkspaceMember, currentWorkspace: currentWorkspace, + appVersion, onTokenPairChange: (tokenPair) => { setTokenPair(tokenPair); }, @@ -73,6 +79,14 @@ export const useApolloFactory = (options: Partial> = {}) => { navigate(AppPath.SignInUp); } }, + onAppVersionMismatch: (message) => { + enqueueErrorSnackBar({ + message, + options: { + dedupeKey: 'app-version-mismatch', + }, + }); + }, extraLinks: [], isDebugMode: process.env.IS_DEBUG_MODE === 'true', // Override options @@ -87,6 +101,7 @@ export const useApolloFactory = (options: Partial> = {}) => { setCurrentWorkspaceMember, setCurrentWorkspace, setPreviousUrl, + enqueueErrorSnackBar, ]); useUpdateEffect(() => { @@ -101,5 +116,11 @@ export const useApolloFactory = (options: Partial> = {}) => { } }, [currentWorkspace]); + useUpdateEffect(() => { + if (isDefined(apolloRef.current)) { + apolloRef.current.updateAppVersion(appVersion); + } + }, [appVersion]); + return apolloClient; }; diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts index 9ea6e72a8..9a8b9c4b6 100644 --- a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts +++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts @@ -64,6 +64,7 @@ const createMockOptions = (): Options => ({ isDebugMode: true, onError: mockOnError, onNetworkError: mockOnNetworkError, + appVersion: '1.0.0', }); const makeRequest = async () => { diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index cddab824c..788b555ec 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -23,6 +23,7 @@ import { logDebug } from '~/utils/logDebug'; import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; import { i18n } from '@lingui/core'; +import { t } from '@lingui/core/macro'; import { DefinitionNode, DirectiveNode, @@ -45,16 +46,19 @@ export interface Options extends ApolloClientOptions { onNetworkError?: (err: Error | ServerParseError | ServerError) => void; onTokenPairChange?: (tokenPair: AuthTokenPair) => void; onUnauthenticatedError?: () => void; + onAppVersionMismatch?: (message: string) => void; currentWorkspaceMember: CurrentWorkspaceMember | null; currentWorkspace: CurrentWorkspace | null; extraLinks?: ApolloLink[]; isDebugMode?: boolean; + appVersion?: string; } export class ApolloFactory implements ApolloManager { private client: ApolloClient; private currentWorkspaceMember: CurrentWorkspaceMember | null = null; private currentWorkspace: CurrentWorkspace | null = null; + private appVersion?: string; constructor(opts: Options) { const { @@ -63,15 +67,18 @@ export class ApolloFactory implements ApolloManager { onNetworkError, onTokenPairChange, onUnauthenticatedError, + onAppVersionMismatch, currentWorkspaceMember, currentWorkspace, extraLinks, isDebugMode, + appVersion, ...options } = opts; this.currentWorkspaceMember = currentWorkspaceMember; this.currentWorkspace = currentWorkspace; + this.appVersion = appVersion; const buildApolloLink = (): ApolloLink => { const uploadLink = createUploadLink({ @@ -111,6 +118,7 @@ export class ApolloFactory implements ApolloManager { ...(this.currentWorkspace?.metadataVersion && { 'X-Schema-Version': `${this.currentWorkspace.metadataVersion}`, }), + ...(this.appVersion && { 'X-App-Version': this.appVersion }), }, }; }); @@ -158,6 +166,13 @@ export class ApolloFactory implements ApolloManager { } switch (graphQLError?.extensions?.code) { + case 'APP_VERSION_MISMATCH': { + onAppVersionMismatch?.( + (graphQLError.extensions?.userFriendlyMessage as string) || + t`Your app version is out of date. Please refresh the page.`, + ); + return; + } case 'UNAUTHENTICATED': { return handleTokenRenewal(operation, forward); } @@ -289,6 +304,10 @@ export class ApolloFactory implements ApolloManager { this.currentWorkspace = workspace; } + updateAppVersion(appVersion?: string) { + this.appVersion = appVersion; + } + getClient() { return this.client; } diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 9ea12fcd5..977bd22e3 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -1,6 +1,7 @@ import { useClientConfig } from '@/client-config/hooks/useClientConfig'; import { aiModelsState } from '@/client-config/states/aiModelsState'; import { apiConfigState } from '@/client-config/states/apiConfigState'; +import { appVersionState } from '@/client-config/states/appVersionState'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { billingState } from '@/client-config/states/billingState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; @@ -97,6 +98,8 @@ export const ClientConfigProviderEffect = () => { isImapSmtpCaldavEnabledState, ); + const setAppVersion = useSetRecoilState(appVersionState); + const { data, loading, error, fetchClientConfig } = useClientConfig(); useEffect(() => { @@ -133,7 +136,7 @@ export const ClientConfigProviderEffect = () => { isErrored: false, error: undefined, })); - + setAppVersion(data.clientConfig.appVersion); setAuthProviders({ google: data?.clientConfig.authProviders.google, microsoft: data?.clientConfig.authProviders.microsoft, @@ -217,6 +220,7 @@ export const ClientConfigProviderEffect = () => { setIsConfigVariablesInDbEnabled, setCalendarBookingPageId, setIsImapSmtpCaldavEnabled, + setAppVersion, ]); return <>; diff --git a/packages/twenty-front/src/modules/client-config/states/appVersionState.ts b/packages/twenty-front/src/modules/client-config/states/appVersionState.ts new file mode 100644 index 000000000..5b226d7f9 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/appVersionState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui/utilities'; + +export const appVersionState = createState({ + key: 'appVersion', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index 77c01f8ed..3286f7800 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -10,6 +10,7 @@ import { } from '~/generated-metadata/graphql'; export type ClientConfig = { + appVersion?: string; aiModels: Array; analyticsEnabled: boolean; api: ApiConfig; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index 1d36c2a41..755bb13f0 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -58,6 +58,7 @@ export class GraphQLConfigService useGraphQLErrorHandlerHook({ metricsService: this.metricsService, exceptionHandlerService: this.exceptionHandlerService, + twentyConfigService: this.twentyConfigService, }), ]; diff --git a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts index 6f5abd9e4..1d52b8c24 100644 --- a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts @@ -39,6 +39,7 @@ export const metadataModuleFactory = async ( useGraphQLErrorHandlerHook({ metricsService: metricsService, exceptionHandlerService, + twentyConfigService, }), useCachedMetadata({ cacheGetter: cacheStorageService.get.bind(cacheStorageService), diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index c13668547..99de96cd5 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -108,6 +108,9 @@ class PublicFeatureFlag { @ObjectType() export class ClientConfig { + @Field(() => String, { nullable: true }) + appVersion?: string; + @Field(() => AuthProviders, { nullable: false }) authProviders: AuthProviders; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index 3e24331c5..09c2cae06 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -100,6 +100,7 @@ describe('ClientConfigService', () => { const result = await service.getClientConfig(); expect(result).toEqual({ + appVersion: '1.0.0', billing: { isBillingEnabled: true, billingUrl: 'https://billing.example.com', diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index a895c7880..7529f1521 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -66,6 +66,7 @@ export class ClientConfigService { } const clientConfig: ClientConfig = { + appVersion: this.twentyConfigService.get('APP_VERSION'), billing: { isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'), billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'), diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts index 3b0f60426..c57df028f 100644 --- a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts +++ b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts @@ -6,6 +6,8 @@ import { } from '@envelop/core'; import { t } from '@lingui/core/macro'; import { GraphQLError, Kind, OperationDefinitionNode, print } from 'graphql'; +import semver from 'semver'; +import { isDefined } from 'twenty-shared/utils'; import { GraphQLContext } from 'src/engine/api/graphql/graphql-config/interfaces/graphql-context.interface'; @@ -18,6 +20,7 @@ import { } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { graphQLErrorCodesToFilter, shouldCaptureException, @@ -26,6 +29,9 @@ import { const DEFAULT_EVENT_ID_KEY = 'exceptionEventId'; const SCHEMA_VERSION_HEADER = 'x-schema-version'; const SCHEMA_MISMATCH_ERROR = 'Schema version mismatch.'; +const APP_VERSION_HEADER = 'x-app-version'; +const APP_VERSION_MISMATCH_ERROR = 'App version mismatch.'; +const APP_VERSION_MISMATCH_CODE = 'APP_VERSION_MISMATCH'; type GraphQLErrorHandlerHookOptions = { metricsService: MetricsService; @@ -34,6 +40,8 @@ type GraphQLErrorHandlerHookOptions = { * The exception handler service to use. */ exceptionHandlerService: ExceptionHandlerService; + + twentyConfigService: TwentyConfigService; /** * The key of the event id in the error's extension. `null` to disable. * @default exceptionEventId @@ -230,17 +238,55 @@ export const useGraphQLErrorHandlerHook = < const headers = context.req.headers; const currentMetadataVersion = context.req.workspaceMetadataVersion; const requestMetadataVersion = headers[SCHEMA_VERSION_HEADER]; + const backendAppVersion = + options.twentyConfigService.get('APP_VERSION'); + const appVersionHeaderValue = headers[APP_VERSION_HEADER]; + const frontEndAppVersion = + appVersionHeaderValue && Array.isArray(appVersionHeaderValue) + ? appVersionHeaderValue[0] + : appVersionHeaderValue; if ( requestMetadataVersion && requestMetadataVersion !== `${currentMetadataVersion}` ) { + options.metricsService.incrementCounter({ + key: MetricsKeys.SchemaVersionMismatch, + }); throw new GraphQLError(SCHEMA_MISMATCH_ERROR, { extensions: { userFriendlyMessage: t`Your workspace has been updated with a new data model. Please refresh the page.`, }, }); } + + if ( + !frontEndAppVersion || + !backendAppVersion || + !semver.valid(frontEndAppVersion) || + !semver.valid(backendAppVersion) + ) { + return; + } + + const frontEndMajor = semver.parse(frontEndAppVersion)?.major; + const backendMajor = semver.parse(backendAppVersion)?.major; + + if ( + isDefined(frontEndMajor) && + isDefined(backendMajor) && + frontEndMajor < backendMajor + ) { + options.metricsService.incrementCounter({ + key: MetricsKeys.AppVersionMismatch, + }); + throw new GraphQLError(APP_VERSION_MISMATCH_ERROR, { + extensions: { + code: APP_VERSION_MISMATCH_CODE, + userFriendlyMessage: t`Your app version is out of date. Please refresh the page to continue.`, + }, + }); + } } }, }; diff --git a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts index 7422a5690..e13f451d1 100644 --- a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts +++ b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts @@ -23,4 +23,6 @@ export enum MetricsKeys { WorkflowRunFailedToEnqueue = 'workflow-run/failed/to-enqueue', AIToolExecutionFailed = 'ai-tool-execution/failed', AIToolExecutionSucceeded = 'ai-tool-execution/succeeded', + SchemaVersionMismatch = 'schema-version/mismatch', + AppVersionMismatch = 'app-version/mismatch', }