Files
textbee/web/lib/api/hooks.ts
isra el 497a14074a refactor: centralize data fetching into typed react-query hooks
Introduce a typed data layer under lib/api (query-key factory, response
types, feature hooks) plus shared lib/format and lib/status helpers, and
migrate the canonical dashboard consumers (subscription-info, overview,
device-list, api-keys) onto it, deleting their duplicated inline queries,
mutations and formatters.

List hooks keep the raw { data: [] } envelope in the cache and unwrap
per-observer with react-query `select`, so shared keys like ['devices']
stay compatible with the not-yet-migrated components that still read the
raw shape (avoids a cache-shape collision surfaced by the dashboard e2e).

Adds unit tests for the formatters and the hooks (against MSW). Build,
19 unit tests, and 2 e2e all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:36:16 +03:00

159 lines
4.4 KiB
TypeScript

import {
useMutation,
useQuery,
useQueryClient,
type UseQueryOptions,
} from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { queryKeys } from './query-keys'
import type {
ApiKey,
ApiKeyStatusFilter,
Device,
GatewayStats,
Plan,
Subscription,
User,
} from './types'
// Most endpoints wrap their payload as { data: ... }; a few (subscription)
// return the object directly. These helpers keep the unwrapping in one place.
const unwrapData = <T>(res: { data: { data: T } }) => res.data.data
const unwrapBody = <T>(res: { data: T }) => res.data
type QueryOpts<T> = Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'>
// List endpoints return { data: T[] }. Legacy (not-yet-migrated) components
// cache that raw envelope under the same query key, so these hooks must keep
// the raw shape in the cache and unwrap per-observer via `select` to avoid a
// cache-shape collision on shared keys like ['devices'] and ['webhooks'].
type ListEnvelope<T> = { data: T[] }
type ListQueryOpts<T> = Omit<
UseQueryOptions<ListEnvelope<T>, Error, T[]>,
'queryKey' | 'queryFn' | 'select'
>
const selectList = <T>(raw: ListEnvelope<T> | undefined): T[] => raw?.data ?? []
// ---------- account ----------
export function useCurrentUser(options?: QueryOpts<User>) {
return useQuery({
queryKey: queryKeys.currentUser,
queryFn: () =>
httpBrowserClient.get(ApiEndpoints.auth.whoAmI()).then(unwrapData<User>),
...options,
})
}
// ---------- billing ----------
export function useSubscription(options?: QueryOpts<Subscription>) {
return useQuery({
queryKey: queryKeys.subscription,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.currentSubscription())
.then(unwrapBody<Subscription>),
...options,
})
}
export function useBillingPlans(options?: ListQueryOpts<Plan>) {
return useQuery({
queryKey: queryKeys.billingPlans,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.plans())
.then((r) => r.data as ListEnvelope<Plan>),
select: selectList<Plan>,
...options,
})
}
// ---------- gateway ----------
export function useGatewayStats(options?: QueryOpts<GatewayStats>) {
return useQuery({
queryKey: queryKeys.stats,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.getStats())
.then(unwrapData<GatewayStats>),
...options,
})
}
export function useDevices(options?: ListQueryOpts<Device>) {
return useQuery({
queryKey: queryKeys.devices,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((r) => r.data as ListEnvelope<Device>),
select: selectList<Device>,
...options,
})
}
export function useDeleteDevice() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
httpBrowserClient.delete(ApiEndpoints.gateway.deleteDevice(id)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.devices })
},
})
}
// ---------- api keys ----------
export function useApiKeys(
status: ApiKeyStatusFilter = 'active',
options?: ListQueryOpts<ApiKey>
) {
return useQuery({
queryKey: queryKeys.apiKeys(status),
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.listApiKeys(status))
.then((r) => r.data as ListEnvelope<ApiKey>),
select: selectList<ApiKey>,
...options,
})
}
export function useRevokeApiKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
httpBrowserClient.post(ApiEndpoints.auth.revokeApiKey(id)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['apiKeys'] })
},
})
}
export function useDeleteApiKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
httpBrowserClient.delete(ApiEndpoints.auth.deleteApiKey(id)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['apiKeys'] })
},
})
}
export function useRenameApiKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
httpBrowserClient.patch(ApiEndpoints.auth.renameApiKey(id), { name }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.apiKeys('active') })
},
})
}