diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e0f9783..e3b5563 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -40,6 +40,64 @@ export class ApiError extends Error { } } +/** + * Turn an arbitrary thrown value from an API mutation into a human-readable, + * actionable toast description. Deployment QA kept reporting generic + * "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP + * status and, when we recognise the code, a hint about what to do next + * (payload too big → ask ops to raise nginx, timeout → retry, etc.). + * + * @param err the error thrown by an `api.*` call (usually an ApiError) + * @param fallback text to show if we can't classify the error + * @param context short context word used in the 413/504 hints + * ("file", "request", "payload" …) + */ +export function describeApiError( + err: unknown, + fallback = "Something went wrong", + context = "request", +): string { + const e = err as { status?: number; message?: string; data?: unknown } | null; + const status = e?.status; + // Try to extract a server-side error body even if the generic ApiError + // message wasn't populated (nginx 413/504 respond with an HTML page so + // `data.error` is empty and `message` looks like "413 "). + const serverMsg = + e?.data && typeof e.data === "object" && "error" in (e.data as object) + ? String((e.data as { error: unknown }).error || "").trim() + : ""; + + if (status === 413) { + return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`; + } + if (status === 504) { + return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`; + } + if (status === 502) { + return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment."; + } + if (status === 401) { + return "Your session expired — please sign in again."; + } + if (status === 403) { + return serverMsg || "You don't have permission to do that (HTTP 403)."; + } + if (status === 404) { + return serverMsg || "The requested item no longer exists (HTTP 404)."; + } + if (status === 422 || status === 400) { + return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`; + } + if (status && status >= 500) { + return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`; + } + + // Network failure / fetch aborted / CORS — ApiError not thrown at all. + if (!status && e?.message) return e.message; + + return fallback; +} + const ACCESS_KEY = "encoach_token"; const REFRESH_KEY = "encoach_refresh_token"; const EXP_KEY = "encoach_token_exp"; diff --git a/src/pages/GenerationPage.tsx b/src/pages/GenerationPage.tsx index 28a260e..c3b4ece 100644 --- a/src/pages/GenerationPage.tsx +++ b/src/pages/GenerationPage.tsx @@ -61,7 +61,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner"; import { generationService } from "@/services/generation.service"; import { mediaService, type Avatar } from "@/services/media.service"; import { examsService } from "@/services/exams.service"; -import { api } from "@/lib/api-client"; +import { api, describeApiError } from "@/lib/api-client"; import { useToast } from "@/hooks/use-toast"; import type { ExamStructure, ExamStructureConfig } from "@/types"; @@ -807,7 +807,12 @@ export default function GenerationPage() { description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`, duration: 8000, }), - onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }), + onError: (err) => + toast({ + variant: "destructive", + title: "Submit failed", + description: describeApiError(err, "Submit failed", "module"), + }), }); const anyGenerating = generatePassageMut.isPending || generateExercisesMut.isPending || diff --git a/src/pages/admin/CustomExamCreate.tsx b/src/pages/admin/CustomExamCreate.tsx index 7828d42..3245566 100644 --- a/src/pages/admin/CustomExamCreate.tsx +++ b/src/pages/admin/CustomExamCreate.tsx @@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useCreateCustomExam, useSaveAsTemplate } from "@/hooks/queries/useExamTemplates"; +import { describeApiError } from "@/lib/api-client"; import { useSubjects } from "@/hooks/queries/useAdaptive"; import type { CustomExamCreateRequest, CustomScoringMethod } from "@/types"; import { toast } from "sonner"; @@ -139,14 +140,11 @@ export default function CustomExamCreate() { return raw?.id ?? raw?.exam_id ?? raw?.data?.id ?? raw?.data?.exam_id; }; - const describeError = (err: unknown, fallback: string): string => { - const e = err as { status?: number; message?: string }; - if (e?.status === 413) return "Payload too large (HTTP 413). Please reduce the number of questions per section and try again."; - if (e?.status === 504) return "Server took too long to respond (HTTP 504). Please retry in a moment."; - if (e?.status === 401) return "Session expired — please sign in again."; - if (e?.message) return `${fallback}: ${e.message}`; - return fallback; - }; + // Thin wrapper around the shared helper so the existing call-sites below + // keep working; the helper lives in api-client so every page gets the same + // actionable 413/504/401 messaging. + const describeError = (err: unknown, fallback: string): string => + describeApiError(err, fallback, "exam"); const onSaveDraft = async () => { // Validate everything before firing the request — previously the Save diff --git a/src/pages/admin/ResourceManager.tsx b/src/pages/admin/ResourceManager.tsx index 2e3d404..4317ab1 100644 --- a/src/pages/admin/ResourceManager.tsx +++ b/src/pages/admin/ResourceManager.tsx @@ -17,6 +17,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { resourcesService } from "@/services/resources.service"; import { coursewareService } from "@/services/courseware.service"; import { taxonomyService } from "@/services/taxonomy.service"; +import { describeApiError } from "@/lib/api-client"; import { useToast } from "@/hooks/use-toast"; import { TaxonomyCascade } from "@/components/TaxonomyCascade"; import type { Resource, ResourceTag } from "@/types"; @@ -274,17 +275,16 @@ export default function ResourceManager() { const uploadMutation = useMutation({ mutationFn: (formData: FormData) => resourcesService.upload(formData), onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); }, - onError: (err: unknown) => { - // Surface status / server message so users can tell 413 (file too big) - // from 401 (session expired) from 500 (server error) without opening - // devtools. Default "Upload failed" hid real failures during QA. - const e = err as { status?: number; message?: string }; - const status = e?.status; - let description = e?.message || "Upload failed"; - if (status === 413) description = "File is too large for the server (HTTP 413). Ask an admin to raise nginx/odoo upload limits."; - else if (status === 401) description = "Session expired — please sign in again."; - else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry."; - toast({ title: "Upload failed", description, variant: "destructive" }); + onError: (err) => { + // describeApiError surfaces status code + actionable next step so QA / + // ops can tell 413 (payload limit) from 504 (timeout) from 401 (session + // expired) without opening DevTools. Default "Upload failed" was + // reported as non-actionable during the Apr-2026 deployment. + toast({ + title: "Upload failed", + description: describeApiError(err, "Upload failed", "file"), + variant: "destructive", + }); }, }); diff --git a/src/pages/teacher/TeacherLibrary.tsx b/src/pages/teacher/TeacherLibrary.tsx index 78303ff..11c1748 100644 --- a/src/pages/teacher/TeacherLibrary.tsx +++ b/src/pages/teacher/TeacherLibrary.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { resourcesService } from "@/services/resources.service"; +import { describeApiError } from "@/lib/api-client"; import { coursewareService } from "@/services/courseware.service"; import { taxonomyService } from "@/services/taxonomy.service"; import { useToast } from "@/hooks/use-toast"; @@ -116,14 +117,12 @@ export default function TeacherLibrary() { setUploadTopicIds([]); setUploadObjectiveIds([]); }, - onError: (err: unknown) => { - const e = err as { status?: number; message?: string }; - const status = e?.status; - let description = e?.message || "Upload failed"; - if (status === 413) description = "File is too large for the server (HTTP 413). Try a smaller file or ask an admin to raise the upload limit."; - else if (status === 401) description = "Session expired — please sign in again."; - else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry."; - toast({ title: "Upload failed", description, variant: "destructive" }); + onError: (err) => { + toast({ + title: "Upload failed", + description: describeApiError(err, "Upload failed", "file"), + variant: "destructive", + }); }, });