refactor: unify api client usage around openapi client

This commit is contained in:
NTumurbars 2025-09-18 16:23:56 +09:00
parent a22b84f128
commit fcc5872fda
13 changed files with 334 additions and 122 deletions

View File

@ -1,13 +1,27 @@
/** /**
* Core API Client * Core API client configuration
* Minimal API client setup using the generated OpenAPI client * Wraps the shared generated client to inject portal-specific behavior like auth headers.
*/ */
import { createClient } from "@customer-portal/api-client"; import {
createClient as createOpenApiClient,
type ApiClient as GeneratedApiClient,
type AuthHeaderResolver,
} from "@customer-portal/api-client";
import { env } from "../config/env"; import { env } from "../config/env";
// Create the type-safe API client const baseUrl = env.NEXT_PUBLIC_API_BASE;
export const apiClient = createClient(env.NEXT_PUBLIC_API_BASE);
// Export the client type for use in hooks let authHeaderResolver: AuthHeaderResolver | undefined;
export type { ApiClient } from "@customer-portal/api-client";
const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.();
export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, {
getAuthHeader: resolveAuthHeader,
});
export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => {
authHeaderResolver = resolver;
};
export type ApiClient = GeneratedApiClient;

View File

@ -1,3 +1,4 @@
export { apiClient } from "./client"; export { apiClient, configureApiClientAuth } from "./client";
export { queryKeys } from "./query-keys"; export { queryKeys } from "./query-keys";
export type { ApiClient } from "./client"; export type { ApiClient } from "./client";
export type { AuthHeaderResolver } from "@customer-portal/api-client";

View File

@ -85,22 +85,11 @@ export function useProfileData() {
const saveProfile = async (next: ProfileFormData) => { const saveProfile = async (next: ProfileFormData) => {
setIsSavingProfile(true); setIsSavingProfile(true);
try { try {
const { tokens } = useAuthStore.getState(); const updatedUser = await accountService.updateProfile({
if (!tokens?.accessToken) throw new Error("Authentication required"); firstName: next.firstName,
const response = await fetch("/api/me", { lastName: next.lastName,
method: "PATCH", phone: next.phone,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.accessToken}`,
},
body: JSON.stringify({
firstName: next.firstName,
lastName: next.lastName,
phone: next.phone,
}),
}); });
if (!response.ok) throw new Error("Failed to update profile");
const updatedUser = (await response.json()) as Partial<typeof user>;
useAuthStore.setState(state => ({ useAuthStore.setState(state => ({
...state, ...state,
user: state.user ? { ...state.user, ...updatedUser } : state.user, user: state.user ? { ...state.user, ...updatedUser } : state.user,

View File

@ -0,0 +1,37 @@
import { apiClient } from "@/core/api";
import type { Address, AuthUser } from "@customer-portal/domain";
const ensureData = <T>(data: T | undefined): T => {
if (typeof data === "undefined") {
throw new Error("No data returned from server");
}
return data;
};
type ProfileUpdateInput = {
firstName?: string;
lastName?: string;
phone?: string;
};
export const accountService = {
async getProfile() {
const response = await apiClient.GET('/me');
return ensureData<AuthUser | null>(response.data);
},
async updateProfile(update: ProfileUpdateInput) {
const response = await apiClient.PATCH('/me', { body: update });
return ensureData<AuthUser>(response.data);
},
async getAddress() {
const response = await apiClient.GET('/me/address');
return ensureData<Address | null>(response.data);
},
async updateAddress(address: Address) {
const response = await apiClient.PATCH('/me/address', { body: address });
return ensureData<Address>(response.data);
},
};

View File

@ -5,7 +5,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; import { persist, createJSONStorage } from "zustand/middleware";
import { apiClient } from "@/core/api"; import { apiClient, configureApiClientAuth } from "@/core/api";
import type { import type {
AuthTokens, AuthTokens,
SignupRequest, SignupRequest,
@ -17,6 +17,62 @@ import type {
AuthError, AuthError,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
const DEFAULT_TOKEN_TYPE = "Bearer";
type RawAuthTokens =
| AuthTokens
| (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date });
const toIsoString = (value: string | number | Date) => {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "number") {
return new Date(value).toISOString();
}
return value;
};
const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenType: tokens.tokenType ?? DEFAULT_TOKEN_TYPE,
expiresAt: toIsoString(tokens.expiresAt),
});
const getExpiryTime = (tokens?: AuthTokens | null) => {
if (!tokens?.expiresAt) {
return null;
}
const expiry = Date.parse(tokens.expiresAt);
return Number.isNaN(expiry) ? null : expiry;
};
const hasValidAccessToken = (tokens?: AuthTokens | null) => {
if (!tokens?.accessToken) {
return false;
}
const expiry = getExpiryTime(tokens);
if (expiry === null) {
return false;
}
return Date.now() < expiry;
};
const buildAuthorizationHeader = (tokens?: AuthTokens | null) => {
if (!tokens?.accessToken) {
return undefined;
}
const tokenType = tokens.tokenType ?? DEFAULT_TOKEN_TYPE;
return `${tokenType} ${tokens.accessToken}`;
};
interface AuthState { interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
user: AuthUser | null; user: AuthUser | null;
@ -54,7 +110,7 @@ interface AuthStoreState extends AuthState {
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
setUser: (user: AuthUser | null) => void; setUser: (user: AuthUser | null) => void;
setTokens: (tokens: AuthTokens | null) => void; setTokens: (tokens: RawAuthTokens | null) => void;
} }
export const useAuthStore = create<AuthStoreState>()( export const useAuthStore = create<AuthStoreState>()(
@ -76,9 +132,10 @@ export const useAuthStore = create<AuthStoreState>()(
try { try {
const response = await apiClient.POST('/auth/login', { body: credentials }); const response = await apiClient.POST('/auth/login', { body: credentials });
if (response.data) { if (response.data) {
const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens);
set({ set({
user: response.data.user, user: response.data.user,
tokens: response.data.tokens, tokens,
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: false,
error: null, error: null,
@ -104,9 +161,10 @@ export const useAuthStore = create<AuthStoreState>()(
try { try {
const response = await apiClient.POST('/auth/signup', { body: data }); const response = await apiClient.POST('/auth/signup', { body: data });
if (response.data) { if (response.data) {
const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens);
set({ set({
user: response.data.user, user: response.data.user,
tokens: response.data.tokens, tokens,
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: false,
error: null, error: null,
@ -164,9 +222,10 @@ export const useAuthStore = create<AuthStoreState>()(
try { try {
const response = await apiClient.POST('/auth/reset-password', { body: data }); const response = await apiClient.POST('/auth/reset-password', { body: data });
const { user, tokens } = response.data!; const { user, tokens } = response.data!;
const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens);
set({ set({
user, user,
tokens, tokens: normalizedTokens,
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: false,
error: null, error: null,
@ -194,9 +253,10 @@ export const useAuthStore = create<AuthStoreState>()(
try { try {
const response = await apiClient.POST('/auth/change-password', { body: data }); const response = await apiClient.POST('/auth/change-password', { body: data });
const { user, tokens: newTokens } = response.data!; const { user, tokens: newTokens } = response.data!;
const normalizedTokens = normalizeAuthTokens(newTokens as RawAuthTokens);
set({ set({
user, user,
tokens: newTokens, tokens: normalizedTokens,
loading: false, loading: false,
error: null, error: null,
}); });
@ -240,9 +300,10 @@ export const useAuthStore = create<AuthStoreState>()(
try { try {
const response = await apiClient.POST('/auth/set-password', { body: { email, password } }); const response = await apiClient.POST('/auth/set-password', { body: { email, password } });
const { user, tokens } = response.data!; const { user, tokens } = response.data!;
const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens);
set({ set({
user, user,
tokens, tokens: normalizedTokens,
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: false,
error: null, error: null,
@ -264,22 +325,26 @@ export const useAuthStore = create<AuthStoreState>()(
checkAuth: async () => { checkAuth: async () => {
const { tokens } = get(); const { tokens } = get();
if (!tokens?.accessToken) { if (!hasValidAccessToken(tokens)) {
set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true }); set({
return; isAuthenticated: false,
} loading: false,
user: null,
// Check if token is expired tokens: null,
if (Date.now() >= tokens.expiresAt) { hasCheckedAuth: true,
set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true }); });
return; return;
} }
set({ loading: true }); set({ loading: true });
try { try {
const response = await apiClient.GET('/me'); const response = await apiClient.GET('/me');
const user = response.data!; const user = response.data ?? null;
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); if (user) {
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
} else {
set({ loading: false, hasCheckedAuth: true });
}
} catch (error) { } catch (error) {
// Token is invalid, clear auth state // Token is invalid, clear auth state
console.info("Token validation failed, clearing auth state"); console.info("Token validation failed, clearing auth state");
@ -301,8 +366,14 @@ export const useAuthStore = create<AuthStoreState>()(
return; return;
} }
const expiry = getExpiryTime(tokens);
if (expiry === null) {
await checkAuth();
return;
}
// Check if token needs refresh (expires within 5 minutes) // Check if token needs refresh (expires within 5 minutes)
if (Date.now() >= tokens.expiresAt - 5 * 60 * 1000) { // 5 minutes before expiry if (Date.now() >= expiry - 5 * 60 * 1000) {
// For now, just re-validate the token // For now, just re-validate the token
// In a real implementation, you would call a refresh token endpoint // In a real implementation, you would call a refresh token endpoint
await checkAuth(); await checkAuth();
@ -318,7 +389,8 @@ export const useAuthStore = create<AuthStoreState>()(
setUser: (user: AuthUser | null) => set({ user }), setUser: (user: AuthUser | null) => set({ user }),
setTokens: (tokens: AuthTokens | null) => set({ tokens }), setTokens: (tokens: RawAuthTokens | null) =>
set({ tokens: tokens ? normalizeAuthTokens(tokens) : null }),
}), }),
{ {
name: "auth-store", name: "auth-store",
@ -352,18 +424,45 @@ export const useAuthStore = create<AuthStoreState>()(
) )
); );
const resolveAuthorizationHeader = () =>
buildAuthorizationHeader(useAuthStore.getState().tokens);
configureApiClientAuth(resolveAuthorizationHeader);
export const getAuthorizationHeader = resolveAuthorizationHeader;
export const selectAuthTokens = (state: AuthStoreState) => state.tokens;
export const selectIsAuthenticated = (state: AuthStoreState) => state.isAuthenticated;
export const selectHasValidAccessToken = (state: AuthStoreState) =>
hasValidAccessToken(state.tokens);
export const useAuthSession = () =>
useAuthStore(state => ({
isAuthenticated: state.isAuthenticated,
hasValidToken: hasValidAccessToken(state.tokens),
tokens: state.tokens,
}));
// Session timeout detection // Session timeout detection
let sessionTimeoutId: NodeJS.Timeout | null = null; let sessionTimeoutId: NodeJS.Timeout | null = null;
export const startSessionTimeout = () => { export const startSessionTimeout = () => {
const checkSession = () => { const checkSession = () => {
const state = useAuthStore.getState(); const state = useAuthStore.getState();
if (state.tokens?.accessToken) { if (!state.tokens?.accessToken) {
if (Date.now() >= state.tokens.expiresAt) { return;
void state.logout(); }
} else {
void state.refreshSession(); const expiry = getExpiryTime(state.tokens);
} if (expiry === null) {
void state.logout();
return;
}
if (Date.now() >= expiry) {
void state.logout();
} else {
void state.refreshSession();
} }
}; };

View File

@ -42,8 +42,11 @@ export function ChangePlanModal({
} }
setLoading(true); setLoading(true);
try { try {
await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, { await apiClient.POST("/subscriptions/{id}/sim/change-plan", {
newPlanCode: newPlanCode, params: { path: { id: subscriptionId } },
body: {
newPlanCode,
},
}); });
onSuccess(); onSuccess();
} catch (e: unknown) { } catch (e: unknown) {

View File

@ -58,7 +58,9 @@ export function SimActions({
setError(null); setError(null);
try { try {
await apiClient.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`); await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", {
params: { path: { id: subscriptionId } },
});
setSuccess("eSIM profile reissued successfully"); setSuccess("eSIM profile reissued successfully");
setShowReissueConfirm(false); setShowReissueConfirm(false);
@ -75,7 +77,9 @@ export function SimActions({
setError(null); setError(null);
try { try {
await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, {}); await apiClient.POST("/subscriptions/{id}/sim/cancel", {
params: { path: { id: subscriptionId } },
});
setSuccess("SIM service cancelled successfully"); setSuccess("SIM service cancelled successfully");
setShowCancelConfirm(false); setShowCancelConfirm(false);

View File

@ -75,10 +75,10 @@ export function SimFeatureToggles({
if (nt !== initial.nt) featurePayload.networkType = nt; if (nt !== initial.nt) featurePayload.networkType = nt;
if (Object.keys(featurePayload).length > 0) { if (Object.keys(featurePayload).length > 0) {
await apiClient.post( await apiClient.POST("/subscriptions/{id}/sim/features", {
`/subscriptions/${subscriptionId}/sim/features`, params: { path: { id: subscriptionId } },
featurePayload body: featurePayload,
); });
} }
setSuccess("Changes submitted successfully"); setSuccess("Changes submitted successfully");

View File

@ -30,24 +30,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
try { try {
setError(null); setError(null);
const data = await apiClient.get<{ const response = await apiClient.GET("/subscriptions/{id}/sim", {
details: SimDetails; params: { path: { id: subscriptionId } },
usage: SimUsage; });
}>(`/subscriptions/${subscriptionId}/sim`);
setSimInfo(data); const payload = response.data as SimInfo | undefined;
if (!payload) {
throw new Error("Failed to load SIM information");
}
setSimInfo(payload);
} catch (err: unknown) { } catch (err: unknown) {
const hasStatus = (v: unknown): v is { status: number } => const hasStatus = (value: unknown): value is { status: number } =>
typeof v === "object" && typeof value === "object" &&
v !== null && value !== null &&
"status" in v && "status" in value &&
typeof (v as { status: unknown }).status === "number"; typeof (value as { status: unknown }).status === "number";
if (hasStatus(err) && err.status === 400) { if (hasStatus(err) && err.status === 400) {
// Not a SIM subscription - this component shouldn't be shown // Not a SIM subscription - this component shouldn't be shown
setError("This subscription is not a SIM service"); setError("This subscription is not a SIM service");
} else { return;
setError(err instanceof Error ? err.message : "Failed to load SIM information");
} }
setError(err instanceof Error ? err.message : "Failed to load SIM information");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -45,7 +45,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
quotaMb: getCurrentAmountMb(), quotaMb: getCurrentAmountMb(),
}; };
await apiClient.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody); await apiClient.POST("/subscriptions/{id}/sim/top-up", {
params: { path: { id: subscriptionId } },
body: requestBody,
});
onSuccess(); onSuccess();
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -4,7 +4,7 @@
*/ */
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth/services/auth.store"; import { useAuthSession } from "@/features/auth/services/auth.store";
import { apiClient } from "@/core/api"; import { apiClient } from "@/core/api";
import type { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain"; import type { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain";
@ -17,26 +17,29 @@ interface UseSubscriptionsOptions {
*/ */
export function useSubscriptions(options: UseSubscriptionsOptions = {}) { export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
const { status } = options; const { status } = options;
const { tokens, isAuthenticated } = useAuthStore(); const { isAuthenticated, hasValidToken } = useAuthSession();
return useQuery<SubscriptionList | Subscription[]>({ return useQuery<SubscriptionList | Subscription[]>({
queryKey: ["subscriptions", status], queryKey: ["subscriptions", status],
queryFn: async () => { queryFn: async () => {
if (!tokens?.accessToken) { if (!hasValidToken) {
throw new Error("Authentication required"); throw new Error("Authentication required");
} }
const params = new URLSearchParams({ const response = await apiClient.GET(
...(status && { status }), "/subscriptions",
}); status ? { params: { query: { status } } } : undefined
const res = await apiClient.get<SubscriptionList | Subscription[]>(
`/subscriptions?${params}`
); );
return res.data as SubscriptionList | Subscription[];
if (!response.data) {
return [] as Subscription[];
}
return response.data as SubscriptionList | Subscription[];
}, },
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
enabled: isAuthenticated && !!tokens?.accessToken, enabled: isAuthenticated && hasValidToken,
}); });
} }
@ -44,21 +47,21 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
* Hook to fetch active subscriptions only * Hook to fetch active subscriptions only
*/ */
export function useActiveSubscriptions() { export function useActiveSubscriptions() {
const { tokens, isAuthenticated } = useAuthStore(); const { isAuthenticated, hasValidToken } = useAuthSession();
return useQuery<Subscription[]>({ return useQuery<Subscription[]>({
queryKey: ["subscriptions", "active"], queryKey: ["subscriptions", "active"],
queryFn: async () => { queryFn: async () => {
if (!tokens?.accessToken) { if (!hasValidToken) {
throw new Error("Authentication required"); throw new Error("Authentication required");
} }
const res = await apiClient.get<Subscription[]>(`/subscriptions/active`); const response = await apiClient.GET("/subscriptions/active");
return res.data as Subscription[]; return (response.data ?? []) as Subscription[];
}, },
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
enabled: isAuthenticated && !!tokens?.accessToken, enabled: isAuthenticated && hasValidToken,
}); });
} }
@ -66,7 +69,7 @@ export function useActiveSubscriptions() {
* Hook to fetch subscription statistics * Hook to fetch subscription statistics
*/ */
export function useSubscriptionStats() { export function useSubscriptionStats() {
const { tokens, isAuthenticated } = useAuthStore(); const { isAuthenticated, hasValidToken } = useAuthSession();
return useQuery<{ return useQuery<{
total: number; total: number;
@ -77,18 +80,18 @@ export function useSubscriptionStats() {
}>({ }>({
queryKey: ["subscriptions", "stats"], queryKey: ["subscriptions", "stats"],
queryFn: async () => { queryFn: async () => {
if (!tokens?.accessToken) { if (!hasValidToken) {
throw new Error("Authentication required"); throw new Error("Authentication required");
} }
const res = await apiClient.get<{ const response = await apiClient.GET("/subscriptions/stats");
total: number; return (response.data ?? {
active: number; total: 0,
suspended: number; active: 0,
cancelled: number; suspended: 0,
pending: number; cancelled: 0,
}>(`/subscriptions/stats`); pending: 0,
return res.data as { }) as {
total: number; total: number;
active: number; active: number;
suspended: number; suspended: number;
@ -98,7 +101,7 @@ export function useSubscriptionStats() {
}, },
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
enabled: isAuthenticated && !!tokens?.accessToken, enabled: isAuthenticated && hasValidToken,
}); });
} }
@ -106,21 +109,28 @@ export function useSubscriptionStats() {
* Hook to fetch a specific subscription * Hook to fetch a specific subscription
*/ */
export function useSubscription(subscriptionId: number) { export function useSubscription(subscriptionId: number) {
const { tokens, isAuthenticated } = useAuthStore(); const { isAuthenticated, hasValidToken } = useAuthSession();
return useQuery<Subscription>({ return useQuery<Subscription>({
queryKey: ["subscription", subscriptionId], queryKey: ["subscription", subscriptionId],
queryFn: async () => { queryFn: async () => {
if (!tokens?.accessToken) { if (!hasValidToken) {
throw new Error("Authentication required"); throw new Error("Authentication required");
} }
const res = await apiClient.get<Subscription>(`/subscriptions/${subscriptionId}`); const response = await apiClient.GET("/subscriptions/{id}", {
return res.data as Subscription; params: { path: { id: subscriptionId } },
});
if (!response.data) {
throw new Error("Subscription not found");
}
return response.data as Subscription;
}, },
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes gcTime: 10 * 60 * 1000, // 10 minutes
enabled: isAuthenticated && !!tokens?.accessToken, enabled: isAuthenticated && hasValidToken,
}); });
} }
@ -132,27 +142,36 @@ export function useSubscriptionInvoices(
options: { page?: number; limit?: number } = {} options: { page?: number; limit?: number } = {}
) { ) {
const { page = 1, limit = 10 } = options; const { page = 1, limit = 10 } = options;
const { tokens, isAuthenticated } = useAuthStore(); const { isAuthenticated, hasValidToken } = useAuthSession();
return useQuery<InvoiceList>({ return useQuery<InvoiceList>({
queryKey: ["subscription-invoices", subscriptionId, page, limit], queryKey: ["subscription-invoices", subscriptionId, page, limit],
queryFn: async () => { queryFn: async () => {
if (!tokens?.accessToken) { if (!hasValidToken) {
throw new Error("Authentication required"); throw new Error("Authentication required");
} }
const params = new URLSearchParams({ const response = await apiClient.GET("/subscriptions/{id}/invoices", {
page: page.toString(), params: {
limit: limit.toString(), path: { id: subscriptionId },
query: { page, limit },
},
}); });
const res = await apiClient.get<InvoiceList>(
`/subscriptions/${subscriptionId}/invoices?${params}` return (
); response.data ?? {
return unwrap(res) as InvoiceList; invoices: [],
pagination: {
page,
totalPages: 0,
totalItems: 0,
},
}
) as InvoiceList;
}, },
staleTime: 60 * 1000, // 1 minute staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes gcTime: 5 * 60 * 1000, // 5 minutes
enabled: isAuthenticated && !!tokens?.accessToken && !!subscriptionId, enabled: isAuthenticated && hasValidToken && !!subscriptionId,
}); });
} }
@ -164,8 +183,10 @@ export function useSubscriptionAction() {
return useMutation({ return useMutation({
mutationFn: async ({ id, action }: { id: number; action: string }) => { mutationFn: async ({ id, action }: { id: number; action: string }) => {
const res = await apiClient.post(`/subscriptions/${id}/actions`, { action }); await apiClient.POST("/subscriptions/{id}/actions", {
return unwrap(res); params: { path: { id } },
body: { action },
});
}, },
onSuccess: (_, { id }) => { onSuccess: (_, { id }) => {
// Invalidate relevant queries after successful action // Invalidate relevant queries after successful action

View File

@ -1,6 +1,7 @@
// Re-export generated client and types
export * as ApiTypes from "./__generated__/types"; export * as ApiTypes from "./__generated__/types";
export { createClient } from "./runtime/client"; export { createClient } from "./runtime/client";
export type { ApiClient } from "./runtime/client"; export type {
ApiClient,
AuthHeaderResolver,
CreateClientOptions,
} from "./runtime/client";

View File

@ -1,12 +1,45 @@
// Defer importing openapi-fetch until codegen deps are installed. import createOpenApiClient from "openapi-fetch";
import createFetchClient from "openapi-fetch";
import type { paths } from "../__generated__/types"; import type { paths } from "../__generated__/types";
export type ApiClient = ReturnType<typeof createFetchClient<paths>>; export type ApiClient = ReturnType<typeof createOpenApiClient<paths>>;
export function createClient(baseUrl: string, _getAuthHeader?: () => string | undefined): ApiClient { export type AuthHeaderResolver = () => string | undefined;
// Consumers can pass headers per call using the client's request options.
return createFetchClient<paths>({ baseUrl }); export interface CreateClientOptions {
getAuthHeader?: AuthHeaderResolver;
} }
export function createClient(
baseUrl: string,
options: CreateClientOptions = {}
): ApiClient {
const client = createOpenApiClient<paths>({
baseUrl,
throwOnError: true,
});
if (typeof client.use === "function" && options.getAuthHeader) {
const resolveAuthHeader = options.getAuthHeader;
client.use({
onRequest({ request }: { request: Request }) {
if (!request || typeof request.headers?.has !== "function") {
return;
}
if (request.headers.has("Authorization")) {
return;
}
const headerValue = resolveAuthHeader();
if (!headerValue) {
return;
}
request.headers.set("Authorization", headerValue);
},
} as never);
}
return client;
}