diff --git a/src/lib/tanstack-query/hooks/use-server-result.ts b/src/lib/tanstack-query/hooks/use-server-result.ts new file mode 100644 index 0000000..ac8af63 --- /dev/null +++ b/src/lib/tanstack-query/hooks/use-server-result.ts @@ -0,0 +1,132 @@ +import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { ServerResult } from '@/lib/tanstack-query/types'; + +export function useServerQuery( + options: Omit, 'queryFn'> & { + queryFn: () => Promise>; + showErrorToast?: boolean; + } +) { + const { queryFn, showErrorToast = true, ...queryOptions } = options; + + return useQuery({ + ...queryOptions, + queryFn: async () => { + const result = await queryFn(); + + if (!result.success) { + if (showErrorToast) { + toast.error(result.error.userMessage); + } + throw new Error(result.error.userMessage); + } + + return result.data; + } + }); +} + +export function useServerSuspenseQuery( + options: Omit, 'queryFn'> & { + queryFn: () => Promise>; + showErrorToast?: boolean; + } +) { + const { queryFn, showErrorToast = true, ...queryOptions } = options; + + return useSuspenseQuery({ + ...queryOptions, + queryFn: async () => { + const result = await queryFn(); + + if (!result.success) { + if (showErrorToast) { + toast.error(result.error.userMessage); + } + throw new Error(result.error.userMessage); + } + + return result.data; + } + }); +} + +export function useServerMutation( + options: Omit, 'mutationFn'> & { + mutationFn: (variables: TVariables) => Promise>; + successMessage?: string; + showErrorToast?: boolean; + showSuccessToast?: boolean; + } +) { + const { + mutationFn, + successMessage, + showErrorToast = true, + showSuccessToast = true, + onSuccess, + onError, + ...mutationOptions + } = options; + + return useMutation({ + ...mutationOptions, + mutationFn: async (variables: TVariables) => { + const result = await mutationFn(variables); + + if (!result.success) { + if (showErrorToast) { + toast.error(result.error.userMessage); + } + throw new Error(result.error.userMessage); + } + + return result.data; + }, + onSuccess: (data, variables, context) => { + if (showSuccessToast && successMessage) { + toast.success(successMessage); + } + onSuccess?.(data, variables, context); + }, + onError: (error, variables, context) => { + onError?.(error, variables, context); + } + }); +} + +export function useOptimisticMutation( + options: Parameters>[0] & { + queryKey: readonly (string | number)[]; + optimisticUpdate?: (oldData: any, variables: TVariables) => any; + } +) { + const queryClient = useQueryClient(); + const { queryKey, optimisticUpdate, ...mutationOptions } = options; + + return useServerMutation({ + ...mutationOptions, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey }); + + const previousData = queryClient.getQueryData(queryKey); + + if (optimisticUpdate && previousData) { + queryClient.setQueryData(queryKey, (old: any) => optimisticUpdate(old, variables)); + } + + return { previousData }; + }, + onError: (error, variables, context) => { + if (context && typeof context === 'object' && 'previousData' in context && context.previousData) { + queryClient.setQueryData(queryKey, context.previousData); + } + mutationOptions.onError?.(error, variables, context); + }, + onSettled: (data, error, variables, context) => { + queryClient.invalidateQueries({ queryKey }); + mutationOptions.onSettled?.(data, error, variables, context); + } + }); +} diff --git a/src/lib/tanstack-query/types.ts b/src/lib/tanstack-query/types.ts new file mode 100644 index 0000000..ba79e40 --- /dev/null +++ b/src/lib/tanstack-query/types.ts @@ -0,0 +1,22 @@ +export type ServerResult = + | { success: true; data: T } + | { success: false; error: ServerError }; + +export type ServerError = { + code: ErrorType; + message: string; + userMessage: string; + statusCode?: number; + context?: Record; +}; + +export enum ErrorType { + VALIDATION = 'VALIDATION', + NOT_FOUND = 'NOT_FOUND', + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + NETWORK = 'NETWORK', + DATABASE = 'DATABASE', + POCKETBASE = 'POCKETBASE', + UNKNOWN = 'UNKNOWN' +} \ No newline at end of file diff --git a/src/lib/tanstack-query/utils.ts b/src/lib/tanstack-query/utils.ts new file mode 100644 index 0000000..a4f1ffd --- /dev/null +++ b/src/lib/tanstack-query/utils.ts @@ -0,0 +1,114 @@ +import { logger } from "../logger"; +import { ErrorType, ServerError, ServerResult } from "./types"; + +export const createServerError = ( + type: ErrorType, + message: string, + userMessage: string, + statusCode?: number, + context?: Record +): ServerError => ({ + code: type, + message, + userMessage, + statusCode, + context, +}); + +export const withErrorHandling = async ( + operation: () => Promise, + context: string, + customErrorMap?: (error: unknown) => ServerError | null +): Promise> => { + try { + const data = await operation(); + return { success: true, data }; + } catch (error) { + logger.error(`${context} failed`, { error, context }); + + if (customErrorMap) { + const customError = customErrorMap(error); + if (customError) { + return { success: false, error: customError }; + } + } + + const mappedError = mapKnownError(error, context); + return { success: false, error: mappedError }; + } +}; + +const mapKnownError = (error: unknown, context: string): ServerError => { + if (error && typeof error === "object" && "status" in error) { + const pbError = error as { + status: number; + message: string; + data?: unknown; + }; + + switch (pbError.status) { + case 400: + return createServerError( + ErrorType.VALIDATION, + pbError.message, + "Invalid request", + 400, + { originalError: pbError.data } + ); + case 401: + return createServerError( + ErrorType.UNAUTHORIZED, + pbError.message, + "You are not authorized to perform this action", + 401 + ); + case 403: + return createServerError( + ErrorType.FORBIDDEN, + pbError.message, + "Access denied", + 403 + ); + case 404: + return createServerError( + ErrorType.NOT_FOUND, + pbError.message, + "The requested resource was not found", + 404 + ); + default: + return createServerError( + ErrorType.POCKETBASE, + pbError.message, + "Something went wrong. Please try again.", + pbError.status + ); + } + } + + if (error instanceof TypeError && error.message.includes("fetch")) { + return createServerError( + ErrorType.NETWORK, + error.message, + "Network error. Please check your connection and try again." + ); + } + + if (error instanceof Error) { + return createServerError( + ErrorType.UNKNOWN, + error.message, + "An unexpected error occurred. Please try again.", + undefined, + { stack: error.stack, context } + ); + } + + return createServerError( + ErrorType.UNKNOWN, + String(error), + "An unexpected error occurred. Please try again.", + undefined, + { originalError: error, context } + ); +};