import { useMutation, useQueryClient, type UseMutationOptions, } from "@tanstack/react-query"; import apiFetch from "../../utils/api"; export interface ApiError extends Error { status?: number; } /** * Full success envelope (`data` + server `message`) — the result shape of a * mutation created with `envelope: true`. Used by the document detail pages * (offers, issued orders) to pass the server's Czech success message through * to the toast instead of a hardcoded client string. */ export interface ApiEnvelope { data?: T; message?: string; } /** * Czech-safe error message for toasts: returns the server-provided message * from an `ApiError` (those carry `.status` and a Czech `error` string), and * falls back when the message is a client-generated English string — network * failures ("Failed to fetch" has no status), "Invalid JSON response", * "Unauthorized" (401) or the generic "Request failed (NNN)". */ export function apiErrorMessage(err: unknown, fallback: string): string { const status = (err as ApiError | null | undefined)?.status; const message = err instanceof Error ? err.message : ""; if (!message || status === undefined || status === 401) return fallback; if (/^Request failed \(\d+\)$/.test(message)) return fallback; return message; } export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE"; interface ApiResponseBody { success: boolean; data?: T; error?: string; message?: string; } async function performMutation(opts: { url: string | ((input: TIn) => string); method: HttpMethod | ((input: TIn) => HttpMethod); body?: TIn; envelope?: boolean; }): Promise { const url = typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url; const method = typeof opts.method === "function" ? opts.method(opts.body as TIn) : opts.method; const response = await apiFetch(url, { method, headers: opts.body !== undefined ? { "Content-Type": "application/json" } : undefined, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }); if (response.status === 401) { const err: ApiError = new Error("Unauthorized"); err.status = 401; throw err; } let result: ApiResponseBody; try { result = (await response.json()) as ApiResponseBody; } catch { throw new Error("Invalid JSON response"); } if (!response.ok || !result.success) { const err: ApiError = new Error( result.error || `Request failed (${response.status})`, ); err.status = response.status; throw err; } if (opts.envelope) { return { data: result.data, message: result.message } as TOut; } return result.data as TOut; } export interface ApiMutationOptions { url: string | ((input: TIn) => string); method: HttpMethod | ((input: TIn) => HttpMethod); /** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */ invalidate?: readonly string[]; /** * When true, the mutation resolves with the full `ApiEnvelope` (`{ data, * message }`) instead of the bare `data` — declare `TOut` as * `ApiEnvelope<...>` accordingly. */ envelope?: boolean; } /** * Hook that wraps `useMutation` with `apiFetch` and broad-invalidation. * * Usage: * const createCategory = useApiMutation({ * url: "/api/admin/warehouse/categories", * method: "POST", * invalidate: ["warehouse"], * }); * createCategory.mutate(form, { * onSuccess: () => { ... }, * }); * * The returned `data` from a successful mutation is `TOut`. Errors are thrown * as `ApiError` (with `.status` attached) so callers can branch on HTTP code. */ export function useApiMutation( opts: ApiMutationOptions & Omit, "mutationFn">, ) { const { url, method, invalidate, envelope, onSuccess, ...rest } = opts; const queryClient = useQueryClient(); return useMutation({ mutationFn: (input: TIn) => performMutation({ url, method, body: input, envelope }), ...rest, onSuccess: (data, variables, onMutateResult, context) => { if (invalidate) { for (const key of invalidate) { queryClient.invalidateQueries({ queryKey: [key] }); } } // Forward to user-provided onSuccess with the 4-arg signature expected by TanStack. ( onSuccess as | ((d: TOut, v: TIn, r: unknown, c: unknown) => void) | undefined )?.(data, variables, onMutateResult, context); }, }); }