mirror of
https://github.com/vernu/textbee.git
synced 2026-07-31 01:17:52 -04:00
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>
159 lines
4.4 KiB
TypeScript
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') })
|
|
},
|
|
})
|
|
}
|