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(