Files
textbee/web/lib/api/hooks.ts
isra el 18e7d1b3d8 perf(web): make dashboard navigation fast and cut session fetches
- prefetch route-tab links so tab clicks reuse a cached payload instead
  of paying an uncached server round trip each time
- add loading.tsx boundaries (dashboard root and each section) so clicks
  paint within a frame; section-level files keep the header and tabs
  mounted while only the content area swaps
- link the sidebar Account item and the checkout page straight to
  /dashboard/account/billing. The /dashboard/account redirect stub stays
  for old links, but no internal link pays the extra hop anymore. The
  stub also dropped query params, which silently ate the plan-change
  success toast.
- set QueryClient defaults (staleTime 60s, no focus refetch, retry 1);
  mutations already invalidate their keys, so the user's own changes
  stay instant. Device messages and webhook deliveries get a 15s
  staleTime since they change from outside the tab.
- replace the axios getCachedSession TTL cache with a token seeded from
  the server session in Providers and kept in sync by a session bridge.
  Requests attach the token synchronously; /api/auth/session is only a
  deduped fallback, instead of a refetch every 2 minutes with a
  thundering herd on expiry.
- swap the billing card's 16px loading spinner for a card-shaped
  skeleton so the tab no longer looks blank while loading

Tests: interceptor seeding/dedupe/signed-out behavior, provider
defaults and token seeding, nav active-state matching, tab prefetch,
billing loading state, plus e2e coverage for direct-to-billing
navigation and an at-most-one-session-call budget guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:49:14 +03:00

432 lines
12 KiB
TypeScript

import {
useMutation,
useQuery,
useQueryClient,
type UseMutationOptions,
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,
WebhookNotification,
WebhookSubscription,
} 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 ?? []
type MutationOpts<TData, TVars> = Omit<
UseMutationOptions<TData, Error, TVars>,
'mutationFn'
>
// ---------- 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: queryKeys.apiKeysAll })
},
})
}
export function useDeleteApiKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
httpBrowserClient.delete(ApiEndpoints.auth.deleteApiKey(id)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.apiKeysAll })
},
})
}
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.apiKeysAll })
},
})
}
// A new key changes the list, the dashboard counts and the device pairing
// state, so all three refresh together.
export function useGenerateApiKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () =>
httpBrowserClient
.post(ApiEndpoints.auth.generateApiKey())
.then((res) => res.data as { data: string }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.apiKeysAll })
void queryClient.invalidateQueries({ queryKey: queryKeys.stats })
void queryClient.invalidateQueries({ queryKey: queryKeys.devices })
},
})
}
// ---------- account ----------
export type UpdateProfilePayload = { name?: string; phone?: string }
export function useUpdateProfile(
options?: MutationOpts<unknown, UpdateProfilePayload>
) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateProfilePayload) =>
httpBrowserClient.patch(ApiEndpoints.auth.updateProfile(), data),
...options,
// Composed, not overridden: a caller passing onSuccess must not silently
// drop the invalidation this hook exists to guarantee.
onSuccess: (...args) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.currentUser })
options?.onSuccess?.(...args)
},
})
}
export type ChangePasswordPayload = {
oldPassword: string
newPassword: string
confirmPassword?: string
}
export function useChangePassword(
options?: MutationOpts<unknown, ChangePasswordPayload>
) {
return useMutation({
mutationFn: (data: ChangePasswordPayload) =>
httpBrowserClient.post(ApiEndpoints.auth.changePassword(), data),
...options,
})
}
export function useSendEmailVerification() {
return useMutation({
mutationFn: () =>
httpBrowserClient.post(ApiEndpoints.auth.sendEmailVerificationEmail()),
})
}
export function useVerifyEmail() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: { userId: string; verificationCode: string }) =>
httpBrowserClient.post(ApiEndpoints.auth.verifyEmail(), payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.currentUser })
},
})
}
export type UpdateOnboardingPayload = {
skipStepId?: string
complete?: boolean
currentStepId?: string
}
export function useUpdateOnboarding() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: UpdateOnboardingPayload) =>
httpBrowserClient
.patch(ApiEndpoints.auth.updateOnboarding(), body)
.then((r) => r.data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.currentUser })
void queryClient.invalidateQueries({ queryKey: queryKeys.stats })
void queryClient.invalidateQueries({ queryKey: queryKeys.subscription })
},
})
}
// ---------- webhooks ----------
export function useWebhooks(options?: ListQueryOpts<WebhookSubscription>) {
return useQuery({
queryKey: queryKeys.webhooks,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.getWebhooks())
.then((r) => r.data as ListEnvelope<WebhookSubscription>),
select: selectList<WebhookSubscription>,
...options,
})
}
export type WebhookInput = {
name?: string
deliveryUrl: string
events: string[]
isActive: boolean
signingSecret: string
}
// All four webhook mutations invalidate the same list, so the key lives here
// once rather than being retyped in each dialog.
const invalidateWebhooks = (queryClient: ReturnType<typeof useQueryClient>) =>
void queryClient.invalidateQueries({ queryKey: queryKeys.webhooks })
export function useCreateWebhook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (values: WebhookInput) =>
httpBrowserClient.post(ApiEndpoints.gateway.createWebhook(), values),
onSuccess: () => invalidateWebhooks(queryClient),
})
}
export function useUpdateWebhook(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (values: Partial<WebhookInput>) =>
httpBrowserClient.patch(ApiEndpoints.gateway.updateWebhook(id), values),
onSuccess: () => invalidateWebhooks(queryClient),
})
}
export function useDeleteWebhook(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () =>
httpBrowserClient.delete(ApiEndpoints.gateway.deleteWebhook(id)),
onSuccess: () => invalidateWebhooks(queryClient),
})
}
export type WebhookNotificationFilters = {
eventType?: string
status?: string
deviceId?: string
webhookSubscriptionId?: string
start?: string
end?: string
page?: number
limit?: number
}
// Raw body: { data: { data: rows[], meta: { totalPages, ... } } }
export type WebhookNotificationsEnvelope = {
data?: {
data?: WebhookNotification[]
meta?: { totalPages?: number; total?: number }
}
}
export function useWebhookNotifications(filters: WebhookNotificationFilters) {
const {
eventType = '',
status = '',
deviceId = '',
webhookSubscriptionId = '',
start = '',
end = '',
page = 1,
limit = 10,
} = filters
return useQuery({
queryKey: [
'webhook-notification',
eventType,
page,
limit,
deviceId,
webhookSubscriptionId,
status,
],
queryFn: () =>
httpBrowserClient
.get(
`${ApiEndpoints.gateway.getWebhookNotifications()}?eventType=${eventType}&page=${page}&limit=${limit}&status=${status}&start=${start}&end=${end}&deviceId=${deviceId}&webhookSubscriptionId=${webhookSubscriptionId}`
)
.then(unwrapBody<WebhookNotificationsEnvelope>),
// Deliveries arrive from outside the tab, so stay fresher than the 60s
// client-wide default.
staleTime: 15_000,
})
}
// ---------- messaging ----------
// Matches the zod-inferred SendSmsFormData shape; validation happens via the
// schema before the payload reaches here.
export type SendSmsPayload = {
// Required: it goes into the request path, so an absent one would POST to
// /gateway/devices/undefined/send-sms. Both callers validate it first.
deviceId: string
recipients?: string[]
message?: string
simSubscriptionId?: number
}
export function useSendSms() {
return useMutation({
mutationKey: ['send-sms'],
mutationFn: (data: SendSmsPayload) =>
httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data),
})
}
export type DeviceMessagesParams = {
type?: string
page?: number
limit?: number
search?: string
}
export type DeviceMessagesEnvelope = {
data: unknown[]
meta?: { page?: number; limit?: number; total?: number; totalPages?: number }
}
// Returns the raw { data, meta } message-history envelope for a device.
// `search` is handled server-side (gateway getMessages matches it against the
// message body, recipient and sender), so it searches all of a device's
// history rather than only the page already loaded.
export function useDeviceMessages(
deviceId: string,
params: DeviceMessagesParams = {},
options?: QueryOpts<DeviceMessagesEnvelope>
) {
const { type = 'all', page = 1, limit = 20, search = '' } = params
return useQuery({
// search joins the key so each term caches separately.
queryKey: queryKeys.deviceMessages(deviceId, { type, page, limit, search }),
enabled: !!deviceId,
queryFn: () => {
const query = new URLSearchParams({
type,
page: String(page),
limit: String(limit),
})
if (search) query.set('search', search)
return httpBrowserClient
.get(`${ApiEndpoints.gateway.getMessages(deviceId)}?${query}`)
.then(unwrapBody<DeviceMessagesEnvelope>)
},
// Inbound messages arrive from outside the tab, so stay fresher than the
// 60s client-wide default. Before ...options so callers can override.
staleTime: 15_000,
...options,
})
}