App version mismatch handling between frontend and backend (#13368)
https://github.com/user-attachments/assets/d153f177-4d70-4ec6-8693-15413e550938
This commit is contained in:
@ -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}
|
||||
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="test-instance-id">
|
||||
{children}
|
||||
</SnackBarComponentInstanceContextProvider>
|
||||
</MemoryRouter>
|
||||
</RecoilRoot>
|
||||
);
|
||||
|
||||
@ -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<Options<any>> = {}) => {
|
||||
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
const appVersion = useRecoilValue(appVersionState);
|
||||
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
@ -35,6 +38,8 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
|
||||
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<Options<any>> = {}) => {
|
||||
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<Options<any>> = {}) => {
|
||||
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<Options<any>> = {}) => {
|
||||
setCurrentWorkspaceMember,
|
||||
setCurrentWorkspace,
|
||||
setPreviousUrl,
|
||||
enqueueErrorSnackBar,
|
||||
]);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
@ -101,5 +116,11 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
if (isDefined(apolloRef.current)) {
|
||||
apolloRef.current.updateAppVersion(appVersion);
|
||||
}
|
||||
}, [appVersion]);
|
||||
|
||||
return apolloClient;
|
||||
};
|
||||
|
||||
@ -64,6 +64,7 @@ const createMockOptions = (): Options<any> => ({
|
||||
isDebugMode: true,
|
||||
onError: mockOnError,
|
||||
onNetworkError: mockOnNetworkError,
|
||||
appVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const makeRequest = async () => {
|
||||
|
||||
@ -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<TCacheShape> extends ApolloClientOptions<TCacheShape> {
|
||||
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<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
private client: ApolloClient<TCacheShape>;
|
||||
private currentWorkspaceMember: CurrentWorkspaceMember | null = null;
|
||||
private currentWorkspace: CurrentWorkspace | null = null;
|
||||
private appVersion?: string;
|
||||
|
||||
constructor(opts: Options<TCacheShape>) {
|
||||
const {
|
||||
@ -63,15 +67,18 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
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<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
...(this.currentWorkspace?.metadataVersion && {
|
||||
'X-Schema-Version': `${this.currentWorkspace.metadataVersion}`,
|
||||
}),
|
||||
...(this.appVersion && { 'X-App-Version': this.appVersion }),
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -158,6 +166,13 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
}
|
||||
|
||||
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<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
this.currentWorkspace = workspace;
|
||||
}
|
||||
|
||||
updateAppVersion(appVersion?: string) {
|
||||
this.appVersion = appVersion;
|
||||
}
|
||||
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
@ -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 <></>;
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const appVersionState = createState<string | undefined>({
|
||||
key: 'appVersion',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export type ClientConfig = {
|
||||
appVersion?: string;
|
||||
aiModels: Array<ClientAiModelConfig>;
|
||||
analyticsEnabled: boolean;
|
||||
api: ApiConfig;
|
||||
|
||||
@ -58,6 +58,7 @@ export class GraphQLConfigService
|
||||
useGraphQLErrorHandlerHook({
|
||||
metricsService: this.metricsService,
|
||||
exceptionHandlerService: this.exceptionHandlerService,
|
||||
twentyConfigService: this.twentyConfigService,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ export const metadataModuleFactory = async (
|
||||
useGraphQLErrorHandlerHook({
|
||||
metricsService: metricsService,
|
||||
exceptionHandlerService,
|
||||
twentyConfigService,
|
||||
}),
|
||||
useCachedMetadata({
|
||||
cacheGetter: cacheStorageService.get.bind(cacheStorageService),
|
||||
|
||||
@ -108,6 +108,9 @@ class PublicFeatureFlag {
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfig {
|
||||
@Field(() => String, { nullable: true })
|
||||
appVersion?: string;
|
||||
|
||||
@Field(() => AuthProviders, { nullable: false })
|
||||
authProviders: AuthProviders;
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user