From f5ef46d91a38fe66ae98d43aa514c7acead762f0 Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Sat, 28 Mar 2026 16:21:03 -0300 Subject: [PATCH] feat: implement error handling for task preview generation and add error message utility --- frontend/src/__tests__/api/tasksApi.test.ts | 14 +++++ .../src/__tests__/pages/EditTaskPage.test.tsx | 4 +- frontend/src/api/errorMessage.ts | 53 +++++++++++++++++++ frontend/src/api/tasksApi.ts | 17 ++++-- frontend/src/pages/EditTaskPage.tsx | 50 +++++++++++++++-- 5 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 frontend/src/api/errorMessage.ts diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts index 4a2ddde..a3d3407 100644 --- a/frontend/src/__tests__/api/tasksApi.test.ts +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -328,4 +328,18 @@ INSTRUCTIONS 'backend unavailable' ) }) + + it('should_returnReadable401Message_when_generateTaskPreviewUnauthorized', async () => { + mockedApiClient.post.mockRejectedValue({ + isAxiosError: true, + response: { + status: 401, + data: {}, + }, + }) + + await expect(generateTaskPreview('task-1', { entity, task: previewTask })).rejects.toThrow( + 'Your session has expired. Please sign in again and retry.' + ) + }) }) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx index a5f962a..a6fc42b 100644 --- a/frontend/src/__tests__/pages/EditTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -205,7 +205,7 @@ describe('EditTaskPage', () => { it('should_showReadablePreviewError_when_generationFails', async () => { vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue( - new Error('Unable to generate a test message from the local model. Connection refused') + new Error('Your session has expired. Please sign in again and retry.') ) renderPage() @@ -215,7 +215,7 @@ describe('EditTaskPage', () => { await waitFor(() => { expect( - screen.getByText(/Unable to generate a test message from the local model. Connection refused/i) + screen.getByText(/Your session has expired. Please sign in again and retry./i) ).toBeInTheDocument() }) }) diff --git a/frontend/src/api/errorMessage.ts b/frontend/src/api/errorMessage.ts new file mode 100644 index 0000000..fd03575 --- /dev/null +++ b/frontend/src/api/errorMessage.ts @@ -0,0 +1,53 @@ +import { isAxiosError } from 'axios' + +function getMessageFromResponseData(data: unknown): string | null { + if (!data || typeof data !== 'object') { + return null + } + + const payload = data as Record + const candidate = payload.message ?? payload.error ?? payload.detail + + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim() + } + + return null +} + +export function getApiErrorMessage(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const status = error.response?.status + const responseMessage = getMessageFromResponseData(error.response?.data) + + if (responseMessage) { + return responseMessage + } + + if (status === 401) { + return 'Your session has expired. Please sign in again and retry.' + } + + if (status === 403) { + return 'You do not have permission to perform this action.' + } + + if (status === 404) { + return 'The requested resource was not found.' + } + + if (status && status >= 500) { + return 'The server failed to process the request. Please try again in a moment.' + } + + if (!error.response) { + return 'Unable to reach the server. Check your network connection and try again.' + } + } + + if (error instanceof Error && error.message.trim().length > 0) { + return error.message + } + + return fallback +} diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index 237fefc..3f87f08 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -1,5 +1,6 @@ import type { VirtualEntityResponse } from './entitiesApi' import apiClient from './apiClient' +import { getApiErrorMessage } from './errorMessage' export type EmailLookback = 'last_day' | 'last_week' | 'last_month' export type GenerationSource = 'openai' | 'llama' @@ -88,11 +89,17 @@ export async function generateTaskPreview( taskId: string, payload: TaskPreviewRequest ): Promise { - const response = await apiClient.post( - `/v1/tasks/${taskId}/generated-messages/generate`, - payload - ) - return response.data.content + try { + const response = await apiClient.post( + `/v1/tasks/${taskId}/generated-messages/generate`, + payload + ) + return response.data.content + } catch (error) { + throw new Error( + getApiErrorMessage(error, 'Unable to generate a test message right now. Please try again.') + ) + } } /** Returns generated message history for one task. */ diff --git a/frontend/src/pages/EditTaskPage.tsx b/frontend/src/pages/EditTaskPage.tsx index a28e6df..78620f4 100644 --- a/frontend/src/pages/EditTaskPage.tsx +++ b/frontend/src/pages/EditTaskPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useNavigate, useParams } from 'react-router-dom' +import { getApiErrorMessage } from '../api/errorMessage' import { getEntity } from '../api/entitiesApi' import { activateTask, @@ -122,6 +123,7 @@ export default function EditTaskPage() { const [taskForm, setTaskForm] = useState(DEFAULT_TASK_FORM) const [selectedMessageId, setSelectedMessageId] = useState('') const [previewError, setPreviewError] = useState('') + const [actionError, setActionError] = useState('') const { data: entity, isLoading: isLoadingEntity } = useQuery({ queryKey: ['entity', entityId], @@ -181,34 +183,58 @@ export default function EditTaskPage() { emailLookback: data.emailLookback, generationSource: data.generationSource, }), + onMutate: () => { + setActionError('') + }, onSuccess: async () => { await invalidateTaskQueries(queryClient, entityId, taskId) navigate(`/entities/${entityId}`) }, + onError: (error) => { + setActionError(getApiErrorMessage(error, 'Unable to save the task right now. Please try again.')) + }, }) const inactivateTaskMutation = useMutation({ mutationFn: () => inactivateTask(taskId), + onMutate: () => { + setActionError('') + }, onSuccess: async () => { await invalidateTaskQueries(queryClient, entityId, taskId) navigate(`/entities/${entityId}`) }, + onError: (error) => { + setActionError(getApiErrorMessage(error, 'Unable to inactivate the task right now. Please try again.')) + }, }) const activateTaskMutation = useMutation({ mutationFn: () => activateTask(taskId), + onMutate: () => { + setActionError('') + }, onSuccess: async () => { await invalidateTaskQueries(queryClient, entityId, taskId) navigate(`/entities/${entityId}`) }, + onError: (error) => { + setActionError(getApiErrorMessage(error, 'Unable to activate the task right now. Please try again.')) + }, }) const deleteTaskMutation = useMutation({ mutationFn: () => deleteTask(taskId), + onMutate: () => { + setActionError('') + }, onSuccess: async () => { await invalidateTaskQueries(queryClient, entityId, taskId) navigate(`/entities/${entityId}`) }, + onError: (error) => { + setActionError(getApiErrorMessage(error, 'Unable to delete the task right now. Please try again.')) + }, }) const previewMutation = useMutation({ @@ -222,9 +248,10 @@ export default function EditTaskPage() { }, onError: (error) => { setPreviewError( - error instanceof Error - ? error.message - : 'Unable to generate a test message from the local model.' + getApiErrorMessage( + error, + 'Unable to generate a test message right now. Please try again.' + ) ) }, }) @@ -268,6 +295,9 @@ export default function EditTaskPage() { const deleteGeneratedMessageMutation = useMutation({ mutationFn: (messageId: string) => deleteTaskGeneratedMessage(taskId, messageId), + onMutate: () => { + setActionError('') + }, onSuccess: ( _data, messageId) => { queryClient.setQueryData( ['task-generated-messages', taskId], @@ -278,6 +308,14 @@ export default function EditTaskPage() { ) => currentMessages?.filter((message) => message.id !== messageId) ?? [] ) }, + onError: (error) => { + setActionError( + getApiErrorMessage( + error, + 'Unable to delete the generated message right now. Please try again.' + ) + ) + }, }) const applyCronParts = (nextCronParts: CronParts) => { @@ -589,6 +627,12 @@ export default function EditTaskPage() { + {actionError && ( +

+ {actionError} +

+ )} +