Files
meezi/web/dashboard/src/lib/api/client.ts
T

253 lines
8.5 KiB
TypeScript
Raw Normal View History

import axios, {
type AxiosError,
type InternalAxiosRequestConfig,
} from "axios";
import type { ApiResponse, AuthTokenResponse } from "./types";
import { getOrCreateTerminalId } from "@/lib/terminal";
import { useAuthStore } from "@/lib/stores/auth.store";
const baseURL =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
export const api = axios.create({
baseURL,
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("meezi_access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers["X-Meezi-Terminal-Id"] = getOrCreateTerminalId();
}
return config;
});
/**
* Shared in-flight refresh promise so that a burst of concurrent 401s triggers
* exactly one POST /api/auth/refresh instead of one per failed request.
*/
let refreshPromise: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
if (typeof window === "undefined") return null;
const refreshToken = localStorage.getItem("meezi_refresh_token");
if (!refreshToken) return null;
try {
// Bare axios call (not `api`) to avoid recursing through this interceptor.
const { data } = await axios.post<ApiResponse<AuthTokenResponse>>(
`${baseURL}/api/auth/refresh`,
{ refreshToken },
{ headers: { "Content-Type": "application/json" } }
);
if (!data.success || !data.data) return null;
useAuthStore.getState().setAuth(data.data);
return data.data.accessToken;
} catch {
return null;
}
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiResponse<unknown>>) => {
const status = error.response?.status;
const original = error.config as
| (InternalAxiosRequestConfig & { _retry?: boolean })
| undefined;
// Expired access token → try a one-time refresh, then replay the request.
if (
status === 401 &&
original &&
!original._retry &&
typeof window !== "undefined" &&
!original.url?.includes("/api/auth/")
) {
original._retry = true;
refreshPromise ??= refreshAccessToken().finally(() => {
refreshPromise = null;
});
const newToken = await refreshPromise;
if (newToken) {
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
}
}
const apiError = error.response?.data?.error;
if (apiError?.code) {
return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status));
}
if (status === 401 && typeof window !== "undefined") {
const path = window.location.pathname;
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
const isAdmin = path.includes("/admin");
if (!isPublicGuest && !isAdmin) {
localStorage.removeItem("meezi_access_token");
localStorage.removeItem("meezi_refresh_token");
localStorage.removeItem("meezi_auth");
const locale = path.split("/")[1] ?? "fa";
window.location.href = `/${locale}/login`;
}
}
return Promise.reject(error);
}
);
export interface PagedMeta {
total: number;
page: number;
pageSize: number;
}
export interface PagedApiResponse<T> {
success: boolean;
data?: T[];
meta?: PagedMeta;
error?: { code: string; message: string; field?: string };
}
export async function apiGet<T>(url: string): Promise<T> {
const { data } = await api.get<ApiResponse<T>>(url);
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiGetPaged<T>(url: string): Promise<{ items: T[]; meta: PagedMeta }> {
const { data } = await api.get<PagedApiResponse<T>>(url);
if (!data.success || data.data === undefined || !data.meta) {
throw new Error(data.error?.message ?? "Request failed");
}
return { items: data.data, meta: data.meta };
}
export class ApiClientError extends Error {
constructor(
public readonly code: string,
message: string,
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
public readonly payload?: unknown,
/** HTTP status, when known — lets callers (e.g. the outbox) tell 5xx (retry) from 4xx (give up). */
public readonly status?: number
) {
super(message);
this.name = "ApiClientError";
}
}
/** Options for mutating requests. An `idempotencyKey` is sent as the
* `Idempotency-Key` header so the server safely de-duplicates retries
* (used by the offline outbox; harmless when omitted). */
export interface WriteOptions {
idempotencyKey?: string;
}
function writeConfig(opts?: WriteOptions) {
if (!opts?.idempotencyKey) return undefined;
return { headers: { "Idempotency-Key": opts.idempotencyKey } };
}
export async function apiPost<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
const { data } = await api.post<ApiResponse<T>>(url, body, writeConfig(opts));
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
}
return data.data;
}
export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
const { data } = await api.put<ApiResponse<T>>(url, body, writeConfig(opts));
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiPatch<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
const { data } = await api.patch<ApiResponse<T>>(url, body, writeConfig(opts));
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
}
export async function apiDelete(url: string, opts?: WriteOptions): Promise<void> {
const { data } = await api.delete<ApiResponse<unknown>>(url, writeConfig(opts));
if (!data.success) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
}
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */
export async function apiGetBlob(path: string): Promise<Blob> {
const response = await api.get(path, { responseType: "blob" });
return response.data as Blob;
}
/** Public GET JSON (no auth required). */
export async function apiGetPublic<T>(path: string): Promise<T> {
const { data } = await api.get<ApiResponse<T>>(path);
if (!data.success || data.data === undefined) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed"
);
}
return data.data;
}
/** Public POST JSON (no auth required). */
export async function apiPostPublic<T, B = unknown>(path: string, body?: B): Promise<T> {
const { data } = await api.post<ApiResponse<T>>(path, body);
if (!data.success || data.data === undefined) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed"
);
}
return data.data;
}
export function openBlobInNewTab(blob: Blob): void {
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
}
export async function apiDownload(path: string, filename: string): Promise<void> {
const blob = await apiGetBlob(path);
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
export async function apiUpload<T>(url: string, file: File): Promise<T> {
const form = new FormData();
form.append("file", file);
const { data } = await api.post<ApiResponse<T>>(url, form, {
headers: { "Content-Type": "multipart/form-data" },
});
if (!data.success || data.data === undefined) {
throw new Error(data.error?.message ?? "Upload failed");
}
return data.data;
}
export function resolveMediaUrl(path?: string | null): string | undefined {
if (!path) return undefined;
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const base = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
return `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
}