diff --git a/hooks/Use-bounty-detail.ts b/hooks/Use-bounty-detail.ts index 3bf88bf..4bb7e5a 100644 --- a/hooks/Use-bounty-detail.ts +++ b/hooks/Use-bounty-detail.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { bountiesApi, type Bounty } from "@/lib/api"; -import { bountyKeys } from "./use-bounties"; +import { bountyKeys } from "@/lib/query/query-keys"; export function useBountyDetail(id: string) { return useQuery({ diff --git a/hooks/use-bounties.ts b/hooks/use-bounties.ts index eeb4ea9..a04b17a 100644 --- a/hooks/use-bounties.ts +++ b/hooks/use-bounties.ts @@ -1,17 +1,15 @@ -import { useQuery } from '@tanstack/react-query'; -import { bountiesApi, type Bounty, type BountyListParams, type PaginatedResponse } from '@/lib/api'; - -export const bountyKeys = { - all: ['bounties'] as const, - lists: () => [...bountyKeys.all, 'list'] as const, - list: (params?: BountyListParams) => [...bountyKeys.lists(), params] as const, - details: () => [...bountyKeys.all, 'detail'] as const, - detail: (id: string) => [...bountyKeys.details(), id] as const, -}; +import { useQuery } from "@tanstack/react-query"; +import { + bountiesApi, + type Bounty, + type BountyListParams, + type PaginatedResponse, +} from "@/lib/api"; +import { bountyKeys } from "@/lib/query/query-keys"; export function useBounties(params?: BountyListParams) { - return useQuery>({ - queryKey: bountyKeys.list(params), - queryFn: () => bountiesApi.list(params), - }); + return useQuery>({ + queryKey: bountyKeys.list(params), + queryFn: () => bountiesApi.list(params), + }); } diff --git a/hooks/use-bounty-mutations.ts b/hooks/use-bounty-mutations.ts index 37063dc..1def94f 100644 --- a/hooks/use-bounty-mutations.ts +++ b/hooks/use-bounty-mutations.ts @@ -1,98 +1,246 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - bountiesApi, - type Bounty, - type CreateBountyInput, - type UpdateBountyInput, - type PaginatedResponse, -} from '@/lib/api'; -import { bountyKeys } from './use-bounties'; + type Bounty, + type CreateBountyInput, + type UpdateBountyInput, + type PaginatedResponse, +} from "@/lib/api"; +import { fetcher } from "@/lib/graphql/client"; +import { bountyKeys } from "@/lib/query/query-keys"; + +const CREATE_BOUNTY_MUTATION = ` + mutation CreateBounty($input: CreateBountyInput!) { + createBounty(input: $input) { + id + } + } +`; + +const UPDATE_BOUNTY_MUTATION = ` + mutation UpdateBounty($input: UpdateBountyInput!) { + updateBounty(input: $input) { + id + status + updatedAt + } + } +`; + +const DELETE_BOUNTY_MUTATION = ` + mutation DeleteBounty($id: ID!) { + deleteBounty(id: $id) + } +`; + +const CLAIM_BOUNTY_MUTATION = ` + mutation ClaimBounty($id: ID!) { + claimBounty(id: $id) { + id + status + updatedAt + } + } +`; + +type GraphQLBountyStatus = "OPEN" | "CLAIMED" | "CLOSED"; + +// UI cache uses the REST status union where "claimed" represents GraphQL "CLAIMED". +const CLAIM_BOUNTY_OPTIMISTIC_STATUS: Bounty["status"] = "claimed"; + +type CreateBountyMutationResponse = { + createBounty: Pick; +}; + +type UpdateBountyMutationResponse = { + updateBounty: { + id: string; + status: GraphQLBountyStatus; + updatedAt: string; + }; +}; + +type DeleteBountyMutationResponse = { + deleteBounty: boolean; +}; + +type ClaimBountyMutationResponse = { + claimBounty: { + id: string; + status: GraphQLBountyStatus; + updatedAt: string; + }; +}; + +type UpdateBountyMutationInput = Omit & { + id: string; + status?: GraphQLBountyStatus; +}; + +async function createBountyMutation( + input: CreateBountyInput, +): Promise> { + const response = await fetcher< + CreateBountyMutationResponse, + { input: CreateBountyInput } + >(CREATE_BOUNTY_MUTATION, { input })(); + + return response.createBounty; +} + +async function updateBountyMutation( + input: UpdateBountyMutationInput, +): Promise { + const response = await fetcher< + UpdateBountyMutationResponse, + { input: UpdateBountyMutationInput } + >(UPDATE_BOUNTY_MUTATION, { input })(); + + return response.updateBounty; +} + +async function claimBountyMutation( + id: string, +): Promise { + const response = await fetcher( + CLAIM_BOUNTY_MUTATION, + { id }, + )(); + + return response.claimBounty; +} + +async function deleteBountyMutation(id: string): Promise { + const response = await fetcher( + DELETE_BOUNTY_MUTATION, + { id }, + )(); + + if (!response.deleteBounty) { + throw new Error(`deleteBounty returned false for id: ${id}`); + } +} export function useCreateBounty() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (data: CreateBountyInput) => bountiesApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); - }, - }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createBountyMutation, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); + }, + }); } export function useUpdateBounty() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ id, data }: { id: string; data: UpdateBountyInput }) => - bountiesApi.update(id, data), - onMutate: async ({ id, data }) => { - await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) }); - const previous = queryClient.getQueryData(bountyKeys.detail(id)); - - if (previous) { - queryClient.setQueryData(bountyKeys.detail(id), { - ...previous, - ...data, - updatedAt: new Date().toISOString(), - }); - } - - return { previous, id }; - }, - onError: (_err, _vars, context) => { - if (context?.previous) { - queryClient.setQueryData(bountyKeys.detail(context.id), context.previous); - } - }, - onSettled: (_data, _err, { id }) => { - queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) }); - queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); - }, - }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateBountyInput }) => + updateBountyMutation({ id, ...data }), + onMutate: async ({ id, data }) => { + await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) }); + const previous = queryClient.getQueryData(bountyKeys.detail(id)); + + if (previous) { + queryClient.setQueryData(bountyKeys.detail(id), { + ...previous, + ...data, + updatedAt: new Date().toISOString(), + }); + } + + return { previous, id }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData( + bountyKeys.detail(context.id), + context.previous, + ); + } + }, + onSettled: (_data, _err, { id }) => { + queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) }); + queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); + }, + }); } export function useDeleteBounty() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (id: string) => bountiesApi.delete(id), - onMutate: async (id) => { - await queryClient.cancelQueries({ queryKey: bountyKeys.lists() }); - - const previousLists = queryClient.getQueriesData>({ - queryKey: bountyKeys.lists(), - }); - - queryClient.setQueriesData>( - { queryKey: bountyKeys.lists() }, - (old) => old ? { - ...old, - data: old.data.filter((b) => b.id !== id), - pagination: { ...old.pagination, total: old.pagination.total - 1 }, - } : old - ); - - return { previousLists }; - }, - onError: (_err, _id, context) => { - context?.previousLists.forEach(([key, data]) => { - queryClient.setQueryData(key, data); - }); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); - }, - }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteBountyMutation, + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: bountyKeys.lists() }); + + const previousLists = queryClient.getQueriesData< + PaginatedResponse + >({ + queryKey: bountyKeys.lists(), + }); + + queryClient.setQueriesData>( + { queryKey: bountyKeys.lists() }, + (old) => + old + ? { + ...old, + data: old.data.filter((b) => b.id !== id), + pagination: { + ...old.pagination, + total: old.pagination.total - 1, + }, + } + : old, + ); + + return { previousLists }; + }, + onError: (_err, _id, context) => { + context?.previousLists.forEach(([key, data]) => { + queryClient.setQueryData(key, data); + }); + }, + onSettled: (_data, _err, id) => { + if (!_err) { + queryClient.removeQueries({ queryKey: bountyKeys.detail(id) }); + } + queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); + }, + }); } export function useClaimBounty() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (id: string) => bountiesApi.claim(id), - onSuccess: (data, id) => { - queryClient.setQueryData(bountyKeys.detail(id), data); - queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); - }, - }); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: claimBountyMutation, + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) }); + const previous = queryClient.getQueryData(bountyKeys.detail(id)); + + if (previous) { + queryClient.setQueryData(bountyKeys.detail(id), { + ...previous, + status: CLAIM_BOUNTY_OPTIMISTIC_STATUS, + updatedAt: new Date().toISOString(), + }); + } + + return { previous, id }; + }, + onError: (_err, _id, context) => { + if (context?.previous) { + queryClient.setQueryData( + bountyKeys.detail(context.id), + context.previous, + ); + } + }, + onSettled: (_data, _err, id) => { + queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) }); + queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); + }, + }); } diff --git a/hooks/use-bounty.ts b/hooks/use-bounty.ts index 97512e4..d228a58 100644 --- a/hooks/use-bounty.ts +++ b/hooks/use-bounty.ts @@ -1,15 +1,15 @@ -import { useQuery } from '@tanstack/react-query'; -import { bountiesApi, type Bounty } from '@/lib/api'; -import { bountyKeys } from './use-bounties'; +import { useQuery } from "@tanstack/react-query"; +import { bountiesApi, type Bounty } from "@/lib/api"; +import { bountyKeys } from "@/lib/query/query-keys"; interface UseBountyOptions { - enabled?: boolean; + enabled?: boolean; } export function useBounty(id: string, options?: UseBountyOptions) { - return useQuery({ - queryKey: bountyKeys.detail(id), - queryFn: () => bountiesApi.getById(id), - enabled: options?.enabled ?? !!id, - }); + return useQuery({ + queryKey: bountyKeys.detail(id), + queryFn: () => bountiesApi.getById(id), + enabled: options?.enabled ?? !!id, + }); } diff --git a/hooks/use-infinite-bounties.ts b/hooks/use-infinite-bounties.ts index ac86e16..ae19c12 100644 --- a/hooks/use-infinite-bounties.ts +++ b/hooks/use-infinite-bounties.ts @@ -1,29 +1,38 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; -import { bountiesApi, type Bounty, type BountyListParams, type PaginatedResponse } from '@/lib/api'; -import { bountyKeys } from './use-bounties'; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { + bountiesApi, + type Bounty, + type BountyListParams, + type PaginatedResponse, +} from "@/lib/api"; +import { bountyKeys } from "@/lib/query/query-keys"; const DEFAULT_LIMIT = 20; -export function useInfiniteBounties(params?: Omit) { - return useInfiniteQuery>({ - queryKey: [...bountyKeys.lists(), 'infinite', params] as const, - queryFn: ({ pageParam }) => - bountiesApi.list({ ...params, page: pageParam as number, limit: params?.limit ?? DEFAULT_LIMIT }), - initialPageParam: 1, - getNextPageParam: (lastPage) => { - const { page, totalPages } = lastPage.pagination; - return page < totalPages ? page + 1 : undefined; - }, - getPreviousPageParam: (firstPage) => { - const { page } = firstPage.pagination; - return page > 1 ? page - 1 : undefined; - }, - }); +export function useInfiniteBounties(params?: Omit) { + return useInfiniteQuery>({ + queryKey: bountyKeys.infinite(params), + queryFn: ({ pageParam }) => + bountiesApi.list({ + ...params, + page: pageParam as number, + limit: params?.limit ?? DEFAULT_LIMIT, + }), + initialPageParam: 1, + getNextPageParam: (lastPage) => { + const { page, totalPages } = lastPage.pagination; + return page < totalPages ? page + 1 : undefined; + }, + getPreviousPageParam: (firstPage) => { + const { page } = firstPage.pagination; + return page > 1 ? page - 1 : undefined; + }, + }); } // Helper to flatten infinite query pages export function flattenBountyPages( - pages: PaginatedResponse[] | undefined + pages: PaginatedResponse[] | undefined, ): Bounty[] { - return pages?.flatMap((page) => page.data) ?? []; + return pages?.flatMap((page) => page.data) ?? []; } diff --git a/lib/query/sync/handlers.ts b/lib/query/sync/handlers.ts index d1c98a0..0f0a5d2 100644 --- a/lib/query/sync/handlers.ts +++ b/lib/query/sync/handlers.ts @@ -1,9 +1,9 @@ -import { QueryClient } from '@tanstack/react-query'; -import { Bounty, PaginatedResponse } from '@/lib/api'; -import { bountyKeys } from '@/hooks/use-bounties'; +import { QueryClient } from "@tanstack/react-query"; +import { Bounty, PaginatedResponse } from "@/lib/api"; +import { bountyKeys } from "@/lib/query/query-keys"; export function handleBountyCreated(queryClient: QueryClient, bounty: Bounty) { - console.log('[Sync] Handling bounty.created:', bounty.id); + console.log("[Sync] Handling bounty.created:", bounty.id); // Update lists queryClient.setQueriesData>( @@ -18,7 +18,7 @@ export function handleBountyCreated(queryClient: QueryClient, bounty: Bounty) { total: oldData.pagination.total + 1, }, }; - } + }, ); // Set detail cache @@ -26,7 +26,7 @@ export function handleBountyCreated(queryClient: QueryClient, bounty: Bounty) { } export function handleBountyUpdated(queryClient: QueryClient, bounty: Bounty) { - console.log('[Sync] Handling bounty.updated:', bounty.id); + console.log("[Sync] Handling bounty.updated:", bounty.id); // Update lists queryClient.setQueriesData>( @@ -37,15 +37,18 @@ export function handleBountyUpdated(queryClient: QueryClient, bounty: Bounty) { ...oldData, data: oldData.data.map((b) => (b.id === bounty.id ? bounty : b)), }; - } + }, ); // Update detail cache queryClient.setQueryData(bountyKeys.detail(bounty.id), bounty); } -export function handleBountyDeleted(queryClient: QueryClient, bountyId: string) { - console.log('[Sync] Handling bounty.deleted:', bountyId); +export function handleBountyDeleted( + queryClient: QueryClient, + bountyId: string, +) { + console.log("[Sync] Handling bounty.deleted:", bountyId); // Update lists queryClient.setQueriesData>( @@ -60,7 +63,7 @@ export function handleBountyDeleted(queryClient: QueryClient, bountyId: string) total: Math.max(0, oldData.pagination.total - 1), }, }; - } + }, ); // Invalidate or remove detail cache