diff --git a/packages/twenty-front/src/modules/ai/components/AIChatErrorMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatErrorMessage.tsx
index 484ec547d0..2918c73b55 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatErrorMessage.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatErrorMessage.tsx
@@ -1,8 +1,12 @@
+import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconAlertCircle } from 'twenty-ui/display';
import { useContext } from 'react';
+
+import { type AIChatError } from '@/ai/types/AIChatError';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
+import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util';
const StyledErrorContainer = styled.div`
align-items: center;
@@ -40,11 +44,14 @@ const StyledErrorMessage = styled.div`
`;
type AIChatErrorMessageProps = {
- error: Error;
+ error: AIChatError;
};
export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => {
const { theme } = useContext(ThemeContext);
+ const errorMessage = CombinedGraphQLErrors.is(error)
+ ? getErrorMessageFromApolloError(error)
+ : error.message;
return (
@@ -54,7 +61,7 @@ export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => {
{t`Failed to get response`}
- {error.message || t`An error occurred while processing your message`}
+ {errorMessage || t`An error occurred while processing your message`}
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatErrorRenderer.tsx b/packages/twenty-front/src/modules/ai/components/AIChatErrorRenderer.tsx
index 139d84de1a..b5ead539a9 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatErrorRenderer.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatErrorRenderer.tsx
@@ -1,19 +1,20 @@
import { AIChatApiKeyNotConfiguredMessage } from '@/ai/components/AIChatApiKeyNotConfiguredMessage';
import { AIChatCreditsExhaustedMessage } from '@/ai/components/AIChatCreditsExhaustedMessage';
import { AIChatErrorMessage } from '@/ai/components/AIChatErrorMessage';
-import { isApiKeyNotConfiguredError } from '@/ai/utils/isApiKeyNotConfiguredError';
-import { isBillingCreditsExhaustedError } from '@/ai/utils/isBillingCreditsExhaustedError';
+import { type AIChatError } from '@/ai/types/AIChatError';
+import { AIChatErrorCode } from '@/ai/utils/aiChatErrorCode';
+import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
type AIChatErrorRendererProps = {
- error: Error;
+ error: AIChatError;
};
export const AIChatErrorRenderer = ({ error }: AIChatErrorRendererProps) => {
- if (isBillingCreditsExhaustedError(error)) {
+ if (isGraphqlErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED)) {
return ;
}
- if (isApiKeyNotConfiguredError(error)) {
+ if (isGraphqlErrorOfType(error, AIChatErrorCode.API_KEY_NOT_CONFIGURED)) {
return ;
}
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
index f023b53484..695adb8b66 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
@@ -6,6 +6,7 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
+import { type AIChatError } from '@/ai/types/AIChatError';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -142,7 +143,7 @@ const StyledFilesContainer = styled.div`
type AIChatMessageProps = {
messageId: string;
isLastMessageStreaming?: boolean;
- error?: Error | undefined;
+ error?: AIChatError | undefined;
};
export const AIChatMessage = ({
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx b/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx
index c276a3d8c0..605f3c18e4 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx
@@ -1,4 +1,5 @@
import { styled } from '@linaria/react';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
@@ -11,9 +12,11 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { isDefined } from 'twenty-shared/utils';
const StyledErrorContainer = styled.div`
- align-items: flex-start;
+ box-sizing: border-box;
display: flex;
+ flex: 1;
flex-direction: column;
+ padding: ${themeCssVariables.spacing[3]};
width: 100%;
`;
diff --git a/packages/twenty-front/src/modules/ai/constants/AgentChatRestoreEditorContentEventName.ts b/packages/twenty-front/src/modules/ai/constants/AgentChatRestoreEditorContentEventName.ts
new file mode 100644
index 0000000000..86c9b16423
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/AgentChatRestoreEditorContentEventName.ts
@@ -0,0 +1,2 @@
+export const AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME =
+ 'agent-chat-restore-editor-content' as const;
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts
index 3ea2213f16..d36d4339f4 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts
@@ -5,9 +5,10 @@ import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Placeholder } from '@tiptap/extensions/placeholder';
import { useEditor } from '@tiptap/react';
-import { useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
+import { AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME } from '@/ai/constants/AgentChatRestoreEditorContentEventName';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
@@ -21,6 +22,7 @@ import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSugges
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
import { MentionTag } from '@/mention/extensions/MentionTag';
import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
+import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@@ -134,6 +136,20 @@ export const useAIChatEditor = () => {
mentionStorage.searchMentionRecords = searchMentionRecords;
}
+ const handleRestoreEditorContent = useCallback(
+ (detail?: { content: string }) => {
+ if (isDefined(detail?.content)) {
+ editor?.commands.setContent(textToTiptapContent(detail.content));
+ }
+ },
+ [editor],
+ );
+
+ useListenToBrowserEvent<{ content: string }>({
+ eventName: AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME,
+ onBrowserEvent: handleRestoreEditorContent,
+ });
+
const handleSendAndClear = () => {
dispatchAgentChatSendMessageEvent();
editor?.commands.clearContent();
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
index ad7ddd29ab..ce1b958f85 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
@@ -1,3 +1,4 @@
+import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useApolloClient } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback, useState } from 'react';
@@ -7,6 +8,7 @@ import { v4 } from 'uuid';
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
+import { AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME } from '@/ai/constants/AgentChatRestoreEditorContentEventName';
import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSendMessageEventName';
import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName';
import { SEND_CHAT_MESSAGE } from '@/ai/graphql/mutations/sendChatMessage';
@@ -15,6 +17,7 @@ import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
+import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
@@ -109,10 +112,15 @@ export const useAgentChat = (
instanceId: AGENT_CHAT_INSTANCE_ID,
familyKey: { threadId },
});
+ const errorAtom = agentChatErrorComponentFamilyState.atomFamily({
+ instanceId: AGENT_CHAT_INSTANCE_ID,
+ familyKey: { threadId },
+ });
const currentMessages = store.get(messagesAtom);
store.set(messagesAtom, [...currentMessages, optimisticUserMessage]);
+ store.set(errorAtom, null);
const fileIds = agentChatUploadedFiles.map((file) => file.fileId);
@@ -155,11 +163,17 @@ export const useAgentChat = (
return null;
});
- } catch {
+ } catch (error) {
+ const restoredDraftKey =
+ draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY ? threadId : draftKey;
+
setAgentChatInput(contentToSend);
setAgentChatDraftsByThreadId((prev) => ({
...prev,
- [draftKey]: contentToSend,
+ [restoredDraftKey]: contentToSend,
+ ...(draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY
+ ? { [AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '' }
+ : {}),
}));
const latestMessages = store.get(messagesAtom);
@@ -168,6 +182,23 @@ export const useAgentChat = (
messagesAtom,
latestMessages.filter((message) => message.id !== messageId),
);
+
+ store.set(
+ errorAtom,
+ CombinedGraphQLErrors.is(error) || error instanceof Error
+ ? error
+ : new Error('An unexpected error occurred'),
+ );
+
+ dispatchBrowserEvent(AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME, {
+ content: contentToSend,
+ });
+
+ if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
+ setCurrentAIChatThread(threadId);
+ }
+
+ setPendingThreadIdAfterFirstSend(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatErrorComponentFamilyState.ts b/packages/twenty-front/src/modules/ai/states/agentChatErrorComponentFamilyState.ts
index 63e6df252c..ffffaae427 100644
--- a/packages/twenty-front/src/modules/ai/states/agentChatErrorComponentFamilyState.ts
+++ b/packages/twenty-front/src/modules/ai/states/agentChatErrorComponentFamilyState.ts
@@ -1,8 +1,12 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
+import { type AIChatError } from '@/ai/types/AIChatError';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export const agentChatErrorComponentFamilyState =
- createAtomComponentFamilyState({
+ createAtomComponentFamilyState<
+ AIChatError | null,
+ { threadId: string | null }
+ >({
key: 'agentChatErrorComponentFamilyState',
defaultValue: null,
componentInstanceContext: AgentChatComponentInstanceContext,
diff --git a/packages/twenty-front/src/modules/ai/types/AIChatError.ts b/packages/twenty-front/src/modules/ai/types/AIChatError.ts
new file mode 100644
index 0000000000..6110a0bd78
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/types/AIChatError.ts
@@ -0,0 +1,3 @@
+import { type CombinedGraphQLErrors } from '@apollo/client/errors';
+
+export type AIChatError = Error | CombinedGraphQLErrors;
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorCode.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorCode.test.ts
deleted file mode 100644
index 6ac10d48da..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorCode.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { extractErrorCode } from '@/ai/utils/extractErrorCode';
-
-describe('extractErrorCode', () => {
- describe('direct error code', () => {
- it('should extract code from error with direct code property', () => {
- const error = { code: 'BILLING_CREDITS_EXHAUSTED', message: 'test' };
- expect(extractErrorCode(error)).toBe('BILLING_CREDITS_EXHAUSTED');
- });
-
- it('should extract code from Error object with code property', () => {
- const error = new Error('test') as Error & { code: string };
- error.code = 'API_KEY_NOT_CONFIGURED';
- expect(extractErrorCode(error)).toBe('API_KEY_NOT_CONFIGURED');
- });
- });
-
- describe('nested error structure', () => {
- it('should extract code from nested error structure', () => {
- const error = {
- error: { code: 'BILLING_CREDITS_EXHAUSTED' },
- };
- expect(extractErrorCode(error)).toBe('BILLING_CREDITS_EXHAUSTED');
- });
-
- it('should extract code from deeply nested error structure', () => {
- const error = {
- data: {
- error: { code: 'API_KEY_NOT_CONFIGURED' },
- },
- };
- expect(extractErrorCode(error)).toBe('API_KEY_NOT_CONFIGURED');
- });
- });
-
- describe('invalid inputs', () => {
- it('should return undefined for null', () => {
- expect(extractErrorCode(null)).toBeUndefined();
- });
-
- it('should return undefined for undefined', () => {
- expect(extractErrorCode(undefined)).toBeUndefined();
- });
-
- it('should return undefined for error without code', () => {
- const error = { message: 'test error' };
- expect(extractErrorCode(error)).toBeUndefined();
- });
-
- it('should return undefined for error with non-string code', () => {
- const error = { code: 123 };
- expect(extractErrorCode(error)).toBeUndefined();
- });
-
- it('should return undefined for string input', () => {
- expect(extractErrorCode('error string')).toBeUndefined();
- });
-
- it('should return undefined for number input', () => {
- expect(extractErrorCode(42)).toBeUndefined();
- });
-
- it('should return undefined for empty object', () => {
- expect(extractErrorCode({})).toBeUndefined();
- });
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorMessage.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorMessage.test.ts
deleted file mode 100644
index e6b980ded0..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorMessage.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { extractErrorMessage } from '@/ai/utils/extractErrorMessage';
-
-describe('extractErrorMessage', () => {
- it('should return the string directly when error is a string', () => {
- expect(extractErrorMessage('Something went wrong')).toBe(
- 'Something went wrong',
- );
- });
-
- it('should extract message from object with message property', () => {
- expect(extractErrorMessage({ message: 'Error occurred' })).toBe(
- 'Error occurred',
- );
- });
-
- it('should extract message from nested error object', () => {
- expect(extractErrorMessage({ error: { message: 'Nested error' } })).toBe(
- 'Nested error',
- );
- });
-
- it('should extract message from deeply nested error object', () => {
- expect(
- extractErrorMessage({
- data: { error: { message: 'Deep nested error' } },
- }),
- ).toBe('Deep nested error');
- });
-
- it('should return fallback message for unknown error shapes', () => {
- const result = extractErrorMessage(42);
-
- expect(typeof result).toBe('string');
- expect(result.length).toBeGreaterThan(0);
- });
-
- it('should return fallback message for null', () => {
- const result = extractErrorMessage(null);
-
- expect(typeof result).toBe('string');
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/isAIChatErrorOfType.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/isAIChatErrorOfType.test.ts
deleted file mode 100644
index bf16d4b864..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/isAIChatErrorOfType.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { AIChatErrorCode } from '@/ai/utils/aiChatErrorCode';
-import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
-
-describe('isAIChatErrorOfType', () => {
- describe('matching error codes', () => {
- it('should return true when error code matches BILLING_CREDITS_EXHAUSTED', () => {
- const error = new Error('test') as Error & { code: string };
- error.code = 'BILLING_CREDITS_EXHAUSTED';
-
- expect(
- isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
- ).toBe(true);
- });
-
- it('should return true when error code matches API_KEY_NOT_CONFIGURED', () => {
- const error = new Error('test') as Error & { code: string };
- error.code = 'API_KEY_NOT_CONFIGURED';
-
- expect(
- isAIChatErrorOfType(error, AIChatErrorCode.API_KEY_NOT_CONFIGURED),
- ).toBe(true);
- });
- });
-
- describe('non-matching error codes', () => {
- it('should return false when error code does not match', () => {
- const error = new Error('test') as Error & { code: string };
- error.code = 'SOME_OTHER_ERROR';
-
- expect(
- isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
- ).toBe(false);
- });
-
- it('should return false when error has no code', () => {
- const error = new Error('test');
-
- expect(
- isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
- ).toBe(false);
- });
- });
-
- describe('null and undefined handling', () => {
- it('should return false for null error', () => {
- expect(
- isAIChatErrorOfType(null, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
- ).toBe(false);
- });
-
- it('should return false for undefined error', () => {
- expect(
- isAIChatErrorOfType(
- undefined,
- AIChatErrorCode.BILLING_CREDITS_EXHAUSTED,
- ),
- ).toBe(false);
- });
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/isApiKeyNotConfiguredError.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/isApiKeyNotConfiguredError.test.ts
deleted file mode 100644
index 7375bf172f..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/isApiKeyNotConfiguredError.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { isApiKeyNotConfiguredError } from '@/ai/utils/isApiKeyNotConfiguredError';
-
-describe('isApiKeyNotConfiguredError', () => {
- it('should return true for API key not configured error', () => {
- const error = new Error('API key not set') as Error & { code: string };
- error.code = 'API_KEY_NOT_CONFIGURED';
-
- expect(isApiKeyNotConfiguredError(error)).toBe(true);
- });
-
- it('should return false for billing credits exhausted error', () => {
- const error = new Error('Credits exhausted') as Error & { code: string };
- error.code = 'BILLING_CREDITS_EXHAUSTED';
-
- expect(isApiKeyNotConfiguredError(error)).toBe(false);
- });
-
- it('should return false for generic error', () => {
- const error = new Error('Something went wrong');
-
- expect(isApiKeyNotConfiguredError(error)).toBe(false);
- });
-
- it('should return false for null', () => {
- expect(isApiKeyNotConfiguredError(null)).toBe(false);
- });
-
- it('should return false for undefined', () => {
- expect(isApiKeyNotConfiguredError(undefined)).toBe(false);
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/isBillingCreditsExhaustedError.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/isBillingCreditsExhaustedError.test.ts
deleted file mode 100644
index 2329c91e04..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/isBillingCreditsExhaustedError.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { isBillingCreditsExhaustedError } from '@/ai/utils/isBillingCreditsExhaustedError';
-
-describe('isBillingCreditsExhaustedError', () => {
- it('should return true for billing credits exhausted error', () => {
- const error = new Error('Credits exhausted') as Error & { code: string };
- error.code = 'BILLING_CREDITS_EXHAUSTED';
-
- expect(isBillingCreditsExhaustedError(error)).toBe(true);
- });
-
- it('should return false for API key not configured error', () => {
- const error = new Error('API key not set') as Error & { code: string };
- error.code = 'API_KEY_NOT_CONFIGURED';
-
- expect(isBillingCreditsExhaustedError(error)).toBe(false);
- });
-
- it('should return false for generic error', () => {
- const error = new Error('Something went wrong');
-
- expect(isBillingCreditsExhaustedError(error)).toBe(false);
- });
-
- it('should return false for null', () => {
- expect(isBillingCreditsExhaustedError(null)).toBe(false);
- });
-
- it('should return false for undefined', () => {
- expect(isBillingCreditsExhaustedError(undefined)).toBe(false);
- });
-});
diff --git a/packages/twenty-front/src/modules/ai/utils/aiChatErrorCode.ts b/packages/twenty-front/src/modules/ai/utils/aiChatErrorCode.ts
index dc8af19d9c..535d559560 100644
--- a/packages/twenty-front/src/modules/ai/utils/aiChatErrorCode.ts
+++ b/packages/twenty-front/src/modules/ai/utils/aiChatErrorCode.ts
@@ -3,6 +3,3 @@ export const AIChatErrorCode = {
BILLING_CREDITS_EXHAUSTED: 'BILLING_CREDITS_EXHAUSTED',
API_KEY_NOT_CONFIGURED: 'API_KEY_NOT_CONFIGURED',
} as const;
-
-export type AIChatErrorCodeType =
- (typeof AIChatErrorCode)[keyof typeof AIChatErrorCode];
diff --git a/packages/twenty-front/src/modules/ai/utils/extractErrorCode.ts b/packages/twenty-front/src/modules/ai/utils/extractErrorCode.ts
deleted file mode 100644
index d500de6be5..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/extractErrorCode.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { isDefined } from 'twenty-shared/utils';
-
-// Type guard for error objects with a code property
-const isErrorWithCode = (
- error: unknown,
-): error is { code: string; message?: string } => {
- return (
- isDefined(error) &&
- typeof error === 'object' &&
- 'code' in error &&
- typeof (error as { code: unknown }).code === 'string'
- );
-};
-
-// Type guard for nested error structures (e.g., { error: { code: '...' } })
-const isNestedErrorWithCode = (
- error: unknown,
-): error is { error: { code: string } } => {
- return (
- isDefined(error) &&
- typeof error === 'object' &&
- 'error' in error &&
- isErrorWithCode((error as { error: unknown }).error)
- );
-};
-
-// Type guard for deeply nested error structures (e.g., { data: { error: { code: '...' } } })
-const isDeepNestedErrorWithCode = (
- error: unknown,
-): error is { data: { error: { code: string } } } => {
- return (
- isDefined(error) &&
- typeof error === 'object' &&
- 'data' in error &&
- isNestedErrorWithCode((error as { data: unknown }).data)
- );
-};
-
-export const extractErrorCode = (error: unknown): string | undefined => {
- if (isErrorWithCode(error)) {
- return error.code;
- }
-
- if (isNestedErrorWithCode(error)) {
- return error.error.code;
- }
-
- if (isDeepNestedErrorWithCode(error)) {
- return error.data.error.code;
- }
-
- return undefined;
-};
diff --git a/packages/twenty-front/src/modules/ai/utils/extractErrorMessage.ts b/packages/twenty-front/src/modules/ai/utils/extractErrorMessage.ts
deleted file mode 100644
index 0693074314..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/extractErrorMessage.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { t } from '@lingui/core/macro';
-import { isDefined } from 'twenty-shared/utils';
-
-const isObjectWithMessage = (error: unknown): error is { message: string } => {
- return (
- isDefined(error) &&
- typeof error === 'object' &&
- 'message' in error &&
- typeof error.message === 'string'
- );
-};
-
-const isErrorWithNestedError = (
- error: unknown,
-): error is {
- error: { message: string };
-} => {
- return (
- isDefined(error) &&
- typeof error === 'object' &&
- 'error' in error &&
- isDefined(error.error) &&
- typeof error.error === 'object' &&
- 'message' in error.error &&
- typeof error.error.message === 'string'
- );
-};
-
-const isDeepNestedError = (
- error: unknown,
-): error is {
- data: { error: { message: string } };
-} => {
- return (
- isDefined(error) &&
- typeof error === 'object' &&
- 'data' in error &&
- isDefined(error.data) &&
- typeof error.data === 'object' &&
- 'error' in error.data &&
- isDefined(error.data.error) &&
- typeof error.data.error === 'object' &&
- 'message' in error.data.error &&
- typeof error.data.error.message === 'string'
- );
-};
-
-export const extractErrorMessage = (error: unknown): string => {
- if (typeof error === 'string') {
- return error;
- }
-
- if (isObjectWithMessage(error)) {
- return error.message;
- }
-
- if (isErrorWithNestedError(error)) {
- return error.error.message;
- }
-
- if (isDeepNestedError(error)) {
- return error.data.error.message;
- }
-
- return t`An unexpected error occurred`;
-};
diff --git a/packages/twenty-front/src/modules/ai/utils/isAIChatErrorOfType.ts b/packages/twenty-front/src/modules/ai/utils/isAIChatErrorOfType.ts
deleted file mode 100644
index ac4c7038ab..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/isAIChatErrorOfType.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { isDefined } from 'twenty-shared/utils';
-
-import { type AIChatErrorCodeType } from '@/ai/utils/aiChatErrorCode';
-import { extractErrorCode } from '@/ai/utils/extractErrorCode';
-
-export const isAIChatErrorOfType = (
- error: Error | null | undefined,
- errorCode: AIChatErrorCodeType,
-): boolean => {
- if (!isDefined(error)) {
- return false;
- }
-
- return extractErrorCode(error) === errorCode;
-};
diff --git a/packages/twenty-front/src/modules/ai/utils/isApiKeyNotConfiguredError.ts b/packages/twenty-front/src/modules/ai/utils/isApiKeyNotConfiguredError.ts
deleted file mode 100644
index b6b8ebd6f4..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/isApiKeyNotConfiguredError.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { AIChatErrorCode } from '@/ai/utils/aiChatErrorCode';
-import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
-
-export const isApiKeyNotConfiguredError = (
- error: Error | null | undefined,
-): boolean => {
- return isAIChatErrorOfType(error, AIChatErrorCode.API_KEY_NOT_CONFIGURED);
-};
diff --git a/packages/twenty-front/src/modules/ai/utils/isBillingCreditsExhaustedError.ts b/packages/twenty-front/src/modules/ai/utils/isBillingCreditsExhaustedError.ts
deleted file mode 100644
index cb628e449a..0000000000
--- a/packages/twenty-front/src/modules/ai/utils/isBillingCreditsExhaustedError.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { AIChatErrorCode } from '@/ai/utils/aiChatErrorCode';
-import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
-
-export const isBillingCreditsExhaustedError = (
- error: Error | null | undefined,
-): boolean => {
- return isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED);
-};
diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx
index afac20d3ab..f6832f7d0a 100644
--- a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx
+++ b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx
@@ -16,6 +16,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
+import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
@@ -102,10 +103,7 @@ export const VerifyEmailEffect = () => {
dedupeKey: 'email-verification-error-dedupe-key',
},
});
- if (
- CombinedGraphQLErrors.is(error) &&
- error.errors[0].extensions?.subCode === 'EMAIL_ALREADY_VERIFIED'
- ) {
+ if (isGraphqlErrorOfType(error, 'EMAIL_ALREADY_VERIFIED')) {
navigate(AppPath.SignInUp);
}
diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
index 51081a0cf9..48d258421a 100644
--- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
@@ -3,7 +3,6 @@ import {
useLazyQuery,
useMutation,
} from '@apollo/client/react';
-import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useCallback } from 'react';
import { AppPath } from 'twenty-shared/types';
@@ -67,6 +66,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
+import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
import { useStore } from 'jotai';
export const useAuth = () => {
@@ -208,11 +208,7 @@ export const useAuth = () => {
return getLoginTokenResult.data.getLoginTokenFromCredentials;
} catch (error) {
- // TODO: Get intellisense for graphql error extensions code (codegen?)
- if (
- CombinedGraphQLErrors.is(error) &&
- error.errors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED'
- ) {
+ if (isGraphqlErrorOfType(error, 'EMAIL_NOT_VERIFIED')) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
throw error;
@@ -334,9 +330,10 @@ export const useAuth = () => {
);
} catch (error) {
if (
- CombinedGraphQLErrors.is(error) &&
- error.errors[0]?.extensions?.subCode ===
- 'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED'
+ isGraphqlErrorOfType(
+ error,
+ 'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
+ )
) {
handleSetLoginToken(loginToken);
navigate(AppPath.SignInUp);
@@ -344,9 +341,10 @@ export const useAuth = () => {
}
if (
- CombinedGraphQLErrors.is(error) &&
- error.errors[0]?.extensions?.subCode ===
- 'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED'
+ isGraphqlErrorOfType(
+ error,
+ 'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED',
+ )
) {
handleSetLoginToken(loginToken);
navigate(AppPath.SignInUp);
@@ -399,10 +397,7 @@ export const useAuth = () => {
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
onError: (error) => {
- if (
- CombinedGraphQLErrors.is(error) &&
- error.errors[0]?.extensions?.subCode === 'EMAIL_NOT_VERIFIED'
- ) {
+ if (isGraphqlErrorOfType(error, 'EMAIL_NOT_VERIFIED')) {
setSearchParams({ email });
setSignInUpStep(SignInUpStep.EmailVerification);
throw error;
diff --git a/packages/twenty-front/src/modules/front-components/hooks/useRequestApplicationTokenRefresh.ts b/packages/twenty-front/src/modules/front-components/hooks/useRequestApplicationTokenRefresh.ts
index 1acafcf109..62611d381b 100644
--- a/packages/twenty-front/src/modules/front-components/hooks/useRequestApplicationTokenRefresh.ts
+++ b/packages/twenty-front/src/modules/front-components/hooks/useRequestApplicationTokenRefresh.ts
@@ -15,6 +15,7 @@ import {
type RenewApplicationTokenMutation,
type RenewApplicationTokenMutationVariables,
} from '~/generated-metadata/graphql';
+import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_SUB_CODE =
'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED';
@@ -22,10 +23,11 @@ const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_SUB_CODE =
const hasApplicationRefreshTokenInvalidOrExpiredSubCode = (
errors: ReadonlyArray,
): boolean =>
- errors.some(
- (error) =>
- error.extensions?.subCode ===
+ errors.some((error) =>
+ isGraphqlErrorOfType(
+ error,
APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_SUB_CODE,
+ ),
);
type UseRequestApplicationTokenRefreshArgs = {
diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEQuerySubscribeEffect.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEQuerySubscribeEffect.tsx
index aa1a31751b..e387c44831 100644
--- a/packages/twenty-front/src/modules/sse-db-event/components/SSEQuerySubscribeEffect.tsx
+++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEQuerySubscribeEffect.tsx
@@ -21,6 +21,7 @@ import {
type AddQuerySubscriptionInput,
type RemoveQueryFromEventStreamInput,
} from '~/generated-metadata/graphql';
+import { getGraphqlErrorExtensionsFromError } from '~/utils/get-graphql-error-extensions-from-error.util';
export const SSEQuerySubscribeEffect = () => {
const store = useStore();
@@ -88,10 +89,14 @@ export const SSEQuerySubscribeEffect = () => {
}
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
- const subCode = error.errors[0]?.extensions?.subCode;
- const code = error.errors[0]?.extensions?.code;
+ const extensions = getGraphqlErrorExtensionsFromError(error);
- if (isGracefullyHandledEventStreamError({ subCode, code })) {
+ if (
+ isGracefullyHandledEventStreamError({
+ subCode: extensions?.subCode,
+ code: extensions?.code,
+ })
+ ) {
store.set(activeQueryListenersState.atom, []);
store.set(shouldDestroyEventStreamState.atom, true);
return;
diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts
index 80aed5f1c0..71d86d3b77 100644
--- a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts
+++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts
@@ -20,6 +20,7 @@ import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type EventSubscription } from '~/generated-metadata/graphql';
+import { getGraphqlErrorExtensionsFromError } from '~/utils/get-graphql-error-extensions-from-error.util';
export const useTriggerEventStreamCreation = () => {
const store = useStore();
@@ -81,10 +82,16 @@ export const useTriggerEventStreamCreation = () => {
}>,
) => {
if (isDefined(value?.errors) && Array.isArray(value.errors)) {
- const subCode = value.errors[0]?.extensions?.subCode;
- const code = value.errors[0]?.extensions?.code;
+ const extensions = getGraphqlErrorExtensionsFromError(
+ value.errors[0],
+ );
- if (!isGracefullyHandledEventStreamError({ subCode, code })) {
+ if (
+ !isGracefullyHandledEventStreamError({
+ subCode: extensions?.subCode,
+ code: extensions?.code,
+ })
+ ) {
captureException(
new Error(
`SSE subscription error: ${value.errors[0]?.message}`,
@@ -137,10 +144,16 @@ export const useTriggerEventStreamCreation = () => {
try {
if (event === 'next') {
if (isDefined(result?.errors)) {
- const subCode = result.errors[0]?.extensions?.subCode;
- const code = result.errors[0]?.extensions?.code;
+ const extensions = getGraphqlErrorExtensionsFromError(
+ result.errors[0],
+ );
- if (!isGracefullyHandledEventStreamError({ subCode, code })) {
+ if (
+ !isGracefullyHandledEventStreamError({
+ subCode: extensions?.subCode,
+ code: extensions?.code,
+ })
+ ) {
for (const error of result.errors) {
captureException(error);
}
diff --git a/packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx b/packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx
index 7a2b047171..3f45b083ee 100644
--- a/packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx
+++ b/packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx
@@ -19,7 +19,6 @@ import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
-import { CombinedGraphQLErrors } from '@apollo/client';
import { useLazyQuery, useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { SettingsPath } from 'twenty-shared/types';
@@ -36,6 +35,7 @@ import {
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
+import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
type SettingsEnterpriseProps = {
isAdminPanelTab?: boolean;
@@ -202,11 +202,7 @@ export const SettingsEnterprise = ({
});
}
} catch (error) {
- if (
- CombinedGraphQLErrors.is(error) &&
- error.errors?.[0]?.extensions?.subCode ===
- 'CONFIG_VARIABLES_IN_DB_DISABLED'
- ) {
+ if (isGraphqlErrorOfType(error, 'CONFIG_VARIABLES_IN_DB_DISABLED')) {
enqueueErrorSnackBar({
apolloError: error,
options: { duration: 10000 },
diff --git a/packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts b/packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts
new file mode 100644
index 0000000000..6813425f20
--- /dev/null
+++ b/packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts
@@ -0,0 +1,76 @@
+import { CombinedGraphQLErrors } from '@apollo/client/errors';
+
+import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
+
+const createCombinedGraphQLError = (
+ errors: Array<{
+ message: string;
+ extensions?: Record;
+ }>,
+): CombinedGraphQLErrors => new CombinedGraphQLErrors({ errors, data: null });
+
+describe('isGraphqlErrorOfType', () => {
+ it('matches a subCode from an Apollo GraphQL error', () => {
+ const error = createCombinedGraphQLError([
+ {
+ message: 'Credits exhausted',
+ extensions: {
+ code: 'FORBIDDEN',
+ subCode: 'BILLING_CREDITS_EXHAUSTED',
+ },
+ },
+ ]);
+
+ expect(isGraphqlErrorOfType(error, 'BILLING_CREDITS_EXHAUSTED')).toBe(true);
+ });
+
+ it('matches the GraphQL code when no subCode is present', () => {
+ const error = createCombinedGraphQLError([
+ {
+ message: 'Unauthorized',
+ extensions: {
+ code: 'UNAUTHENTICATED',
+ },
+ },
+ ]);
+
+ expect(isGraphqlErrorOfType(error, 'UNAUTHENTICATED')).toBe(true);
+ });
+
+ it('matches a plain error code property', () => {
+ const error = new Error('Stream failed') as Error & { code: string };
+
+ error.code = 'AGENT_EXECUTION_FAILED';
+
+ expect(isGraphqlErrorOfType(error, 'AGENT_EXECUTION_FAILED')).toBe(true);
+ });
+
+ it('matches a subCode from a plain GraphQLFormattedError object', () => {
+ const error = {
+ message: 'Refresh token expired',
+ extensions: {
+ subCode: 'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED',
+ },
+ };
+
+ expect(
+ isGraphqlErrorOfType(
+ error,
+ 'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED',
+ ),
+ ).toBe(true);
+ });
+
+ it('returns false when the error code does not match', () => {
+ const error = createCombinedGraphQLError([
+ {
+ message: 'Unauthorized',
+ extensions: {
+ code: 'UNAUTHENTICATED',
+ },
+ },
+ ]);
+
+ expect(isGraphqlErrorOfType(error, 'FORBIDDEN')).toBe(false);
+ });
+});
diff --git a/packages/twenty-front/src/utils/get-graphql-error-extensions-from-error.util.ts b/packages/twenty-front/src/utils/get-graphql-error-extensions-from-error.util.ts
new file mode 100644
index 0000000000..bbdf0b9874
--- /dev/null
+++ b/packages/twenty-front/src/utils/get-graphql-error-extensions-from-error.util.ts
@@ -0,0 +1,26 @@
+import { CombinedGraphQLErrors } from '@apollo/client/errors';
+import { isDefined } from 'twenty-shared/utils';
+
+export const getGraphqlErrorExtensionsFromError = (
+ error: unknown,
+): Record | undefined => {
+ if (CombinedGraphQLErrors.is(error)) {
+ const extensions = error.errors?.[0]?.extensions;
+
+ return isDefined(extensions)
+ ? (extensions as Record)
+ : undefined;
+ }
+
+ if (
+ isDefined(error) &&
+ typeof error === 'object' &&
+ 'extensions' in error &&
+ isDefined(error.extensions) &&
+ typeof error.extensions === 'object'
+ ) {
+ return error.extensions as Record;
+ }
+
+ return undefined;
+};
diff --git a/packages/twenty-front/src/utils/is-graphql-error-of-type.util.ts b/packages/twenty-front/src/utils/is-graphql-error-of-type.util.ts
new file mode 100644
index 0000000000..574fbfeced
--- /dev/null
+++ b/packages/twenty-front/src/utils/is-graphql-error-of-type.util.ts
@@ -0,0 +1,21 @@
+import { type ErrorLike } from '@apollo/client';
+import { isDefined } from 'twenty-shared/utils';
+
+import { getGraphqlErrorExtensionsFromError } from '~/utils/get-graphql-error-extensions-from-error.util';
+
+export const isGraphqlErrorOfType = (
+ error: unknown,
+ errorCode: string,
+): error is ErrorLike => {
+ if (!isDefined(error) || typeof error !== 'object') {
+ return false;
+ }
+
+ const extensions = getGraphqlErrorExtensionsFromError(error);
+
+ return (
+ (isDefined(extensions?.subCode) && extensions.subCode === errorCode) ||
+ (isDefined(extensions?.code) && extensions.code === errorCode) ||
+ ('code' in error && error.code === errorCode)
+ );
+};
diff --git a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
index cbae0112a7..5095b8c221 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
@@ -14,6 +14,7 @@ import {
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
+import { getBillingExceptionStatusCode } from 'src/engine/core-modules/billing/utils/get-billing-exception-status-code.util';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(BillingException, Stripe.errors.StripeError)
@@ -41,42 +42,10 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
);
}
- switch (exception.code) {
- case BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND:
- case BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND:
- case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
- case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
- case BillingExceptionCode.BILLING_METER_NOT_FOUND:
- case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
- case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND:
- return this.httpExceptionHandlerService.handleError(
- exception,
- response,
- 404,
- );
- case BillingExceptionCode.BILLING_METER_EVENT_FAILED:
- case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD:
- case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE:
- case BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE:
- case BillingExceptionCode.BILLING_MISSING_REQUEST_BODY:
- return this.httpExceptionHandlerService.handleError(
- exception,
- response,
- 400,
- );
- case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED:
- return this.httpExceptionHandlerService.handleError(
- exception,
- response,
- 402,
- );
- case BillingExceptionCode.BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND:
- default:
- return this.httpExceptionHandlerService.handleError(
- exception,
- response,
- 500,
- );
- }
+ return this.httpExceptionHandlerService.handleError(
+ exception,
+ response,
+ getBillingExceptionStatusCode(exception),
+ );
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-graphql-api-exception.filter.ts
new file mode 100644
index 0000000000..48ee4140c8
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-graphql-api-exception.filter.ts
@@ -0,0 +1,27 @@
+/* @license Enterprise */
+
+import {
+ type ArgumentsHost,
+ Catch,
+ type ExceptionFilter,
+} from '@nestjs/common';
+import { type GqlContextType } from '@nestjs/graphql';
+
+import Stripe from 'stripe';
+
+import { BillingException } from 'src/engine/core-modules/billing/billing.exception';
+import { billingGraphqlApiExceptionHandler } from 'src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util';
+
+@Catch(BillingException, Stripe.errors.StripeError)
+export class BillingGraphqlApiExceptionFilter implements ExceptionFilter {
+ catch(
+ exception: BillingException | Stripe.errors.StripeError,
+ host: ArgumentsHost,
+ ) {
+ if (host.getType() !== 'graphql') {
+ throw exception;
+ }
+
+ return billingGraphqlApiExceptionHandler(exception);
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts
new file mode 100644
index 0000000000..15b6869a2a
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts
@@ -0,0 +1,65 @@
+/* @license Enterprise */
+
+import {
+ BillingException,
+ BillingExceptionCode,
+} from 'src/engine/core-modules/billing/billing.exception';
+import { billingGraphqlApiExceptionHandler } from 'src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util';
+import {
+ ErrorCode,
+ type BaseGraphQLError,
+} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
+
+const catchGraphqlError = (error: Error): BaseGraphQLError => {
+ try {
+ billingGraphqlApiExceptionHandler(error);
+ throw new Error('Expected billingGraphqlApiExceptionHandler to throw');
+ } catch (graphqlError) {
+ return graphqlError as BaseGraphQLError;
+ }
+};
+
+describe('billingGraphqlApiExceptionHandler', () => {
+ it('maps credits exhausted to a GraphQL error with the billing subCode', () => {
+ const error = new BillingException(
+ 'Credits exhausted',
+ BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
+ );
+
+ const graphqlError = catchGraphqlError(error);
+
+ expect(graphqlError.extensions.code).toBe(ErrorCode.FORBIDDEN);
+ expect(graphqlError.extensions.subCode).toBe(
+ BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
+ );
+ expect(graphqlError.extensions.userFriendlyMessage).toBeDefined();
+ });
+
+ it('maps billing not found errors to NOT_FOUND', () => {
+ const error = new BillingException(
+ 'Billing product not found',
+ BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
+ );
+
+ const graphqlError = catchGraphqlError(error);
+
+ expect(graphqlError.extensions.code).toBe(ErrorCode.NOT_FOUND);
+ expect(graphqlError.extensions.subCode).toBe(
+ BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
+ );
+ });
+
+ it('maps internal billing failures to INTERNAL_SERVER_ERROR', () => {
+ const error = new BillingException(
+ 'Invalid price tiers',
+ BillingExceptionCode.BILLING_PRICE_INVALID_TIERS,
+ );
+
+ const graphqlError = catchGraphqlError(error);
+
+ expect(graphqlError.extensions.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
+ expect(graphqlError.extensions.subCode).toBe(
+ BillingExceptionCode.BILLING_PRICE_INVALID_TIERS,
+ );
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util.ts
new file mode 100644
index 0000000000..9c1986e031
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util.ts
@@ -0,0 +1,40 @@
+/* @license Enterprise */
+
+import { msg } from '@lingui/core/macro';
+import Stripe from 'stripe';
+
+import {
+ BillingException,
+ BillingExceptionCode,
+} from 'src/engine/core-modules/billing/billing.exception';
+import {
+ ForbiddenError,
+ InternalServerError,
+ NotFoundError,
+ UserInputError,
+} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
+import { getBillingExceptionStatusCode } from 'src/engine/core-modules/billing/utils/get-billing-exception-status-code.util';
+
+export const billingGraphqlApiExceptionHandler = (error: Error) => {
+ if (error instanceof Stripe.errors.StripeError) {
+ throw new InternalServerError(error.message, {
+ subCode: BillingExceptionCode.BILLING_STRIPE_ERROR,
+ userFriendlyMessage: msg`A payment processing error occurred.`,
+ });
+ }
+
+ if (error instanceof BillingException) {
+ switch (getBillingExceptionStatusCode(error)) {
+ case 404:
+ throw new NotFoundError(error);
+ case 400:
+ throw new UserInputError(error);
+ case 402:
+ throw new ForbiddenError(error);
+ case 500:
+ throw new InternalServerError(error);
+ }
+ }
+
+ throw error;
+};
diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts
new file mode 100644
index 0000000000..c91dc80571
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts
@@ -0,0 +1,47 @@
+/* @license Enterprise */
+
+import { assertUnreachable } from 'twenty-shared/utils';
+
+import {
+ BillingException,
+ BillingExceptionCode,
+} from 'src/engine/core-modules/billing/billing.exception';
+
+export const getBillingExceptionStatusCode = (
+ exception: BillingException,
+): 400 | 402 | 404 | 500 => {
+ switch (exception.code) {
+ case BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND:
+ case BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND:
+ case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
+ case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
+ case BillingExceptionCode.BILLING_METER_NOT_FOUND:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND:
+ return 404;
+ case BillingExceptionCode.BILLING_METER_EVENT_FAILED:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE:
+ case BillingExceptionCode.BILLING_MISSING_REQUEST_BODY:
+ return 400;
+ case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED:
+ return 402;
+ case BillingExceptionCode.BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND:
+ case BillingExceptionCode.BILLING_PRICE_NOT_FOUND:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_INVALID:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND:
+ case BillingExceptionCode.BILLING_UNHANDLED_ERROR:
+ case BillingExceptionCode.BILLING_STRIPE_ERROR:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_INVALID:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_INVALID:
+ case BillingExceptionCode.BILLING_PRICE_INVALID_TIERS:
+ case BillingExceptionCode.BILLING_PRICE_INVALID:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND:
+ case BillingExceptionCode.BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND:
+ return 500;
+ default: {
+ return assertUnreachable(exception.code);
+ }
+ }
+};
diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts
index 7e11d723e8..f05bb46ab4 100644
--- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
-import { HttpAdapterHost } from '@nestjs/core';
+import { APP_FILTER, HttpAdapterHost } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
@@ -20,6 +20,7 @@ import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-acc
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingWebhookModule } from 'src/engine/core-modules/billing-webhook/billing-webhook.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
+import { BillingGraphqlApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-graphql-api-exception.filter';
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/timeline-calendar-event.module';
import { CaptchaModule } from 'src/engine/core-modules/captcha/captcha.module';
@@ -164,6 +165,12 @@ import { FileModule } from './file/file.module';
DashboardModule,
EventLogsModule,
],
+ providers: [
+ {
+ provide: APP_FILTER,
+ useClass: BillingGraphqlApiExceptionFilter,
+ },
+ ],
exports: [
AuditModule,
AuthModule,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts
new file mode 100644
index 0000000000..d78ee666a9
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts
@@ -0,0 +1,35 @@
+import {
+ AgentException,
+ AgentExceptionCode,
+} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
+import { agentGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util';
+import {
+ ErrorCode,
+ type BaseGraphQLError,
+} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
+
+const catchGraphqlError = (error: Error): BaseGraphQLError => {
+ try {
+ agentGraphqlApiExceptionHandler(error);
+ throw new Error('Expected agentGraphqlApiExceptionHandler to throw');
+ } catch (graphqlError) {
+ return graphqlError as BaseGraphQLError;
+ }
+};
+
+describe('agentGraphqlApiExceptionHandler', () => {
+ it('maps API key configuration failures to INTERNAL_SERVER_ERROR with a subCode', () => {
+ const error = new AgentException(
+ 'No AI models are available',
+ AgentExceptionCode.API_KEY_NOT_CONFIGURED,
+ );
+
+ const graphqlError = catchGraphqlError(error);
+
+ expect(graphqlError.extensions.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
+ expect(graphqlError.extensions.subCode).toBe(
+ AgentExceptionCode.API_KEY_NOT_CONFIGURED,
+ );
+ expect(graphqlError.extensions.userFriendlyMessage).toBeDefined();
+ });
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts
index 7cd7181575..5a29a306f7 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts
@@ -3,6 +3,7 @@ import { assertUnreachable } from 'twenty-shared/utils';
import {
ConflictError,
ForbiddenError,
+ InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -27,7 +28,7 @@ export const agentGraphqlApiExceptionHandler = (error: Error) => {
case AgentExceptionCode.AGENT_EXECUTION_FAILED:
case AgentExceptionCode.API_KEY_NOT_CONFIGURED:
case AgentExceptionCode.USER_WORKSPACE_ID_NOT_FOUND:
- throw error;
+ throw new InternalServerError(error);
default: {
return assertUnreachable(error.code);
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts
index 754555df53..d11d480202 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts
@@ -25,6 +25,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
+import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
@@ -114,6 +115,7 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser
MessagePruningService,
StreamAgentChatJob,
SystemPromptBuilderService,
+ AgentGraphqlApiExceptionInterceptor,
],
exports: [
AgentChatService,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts
index bd25fbe302..e4c1f254b5 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts
@@ -1,4 +1,4 @@
-import { UseGuards } from '@nestjs/common';
+import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Subscription } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
@@ -19,6 +19,7 @@ import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
+import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor';
import { AgentChatEventDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-event.dto';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
@@ -26,6 +27,7 @@ import { FeatureFlagKey } from 'twenty-shared/types';
@MetadataResolver()
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
+@UseInterceptors(AgentGraphqlApiExceptionInterceptor)
export class AgentChatSubscriptionResolver {
constructor(
private readonly subscriptionService: SubscriptionService,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts
index 355910db5d..d28d3ca1bf 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts
@@ -1,4 +1,4 @@
-import { UseGuards } from '@nestjs/common';
+import { UseGuards, UseInterceptors } from '@nestjs/common';
import {
Args,
Float,
@@ -39,6 +39,7 @@ import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
+import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor';
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto';
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto';
@@ -58,6 +59,7 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.AI),
)
+@UseInterceptors(AgentGraphqlApiExceptionInterceptor)
@MetadataResolver(() => AgentChatThreadDTO)
export class AgentChatResolver {
constructor(