Files
meezi/web/dashboard/src/lib/api/client.ts
T
soroush.asadi eb165db182
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 38s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m11s
feat(offline): make every dashboard write durable offline (P2–P5)
Builds on the outbox engine to take the whole dashboard offline in one place
instead of wiring 114 mutation sites individually.

Frontend (single chokepoint = the API client):
- offline-write: any write auto-queues to the outbox on offline/network failure
  and returns an optimistic value; the online path is unchanged apart from an
  Idempotency-Key header (so even online retries de-dup). entityType is derived
  from the URL; POSTs get a remappable local id.
- client.doWrite unifies POST/PUT/PATCH/DELETE through this path. WriteOptions
  gains `offline: "queue" | "reject" | "manual"`.
- Guardrails: auth / billing / payments / SMS / exports are online-only and throw
  OFFLINE_UNAVAILABLE offline rather than queueing (no queued double-charges or
  surprise SMS blasts). use-api-error resolves the friendly localized message
  (fa/en/ar).
- submit-order opts out ("manual") to keep its richer local-Order mock; shared
  helpers de-duplicated into offline-write.
- Request persistent storage on mount so unsynced writes survive eviction.

Backend:
- IdempotencyCleanupJob: daily purge of idempotency records older than 7 days
  (the table now gets a row per keyed write). Registered in Hangfire. No migration.

86 API tests pass; dashboard tsc + build clean.
2026-06-02 18:34:54 +03:30

310 lines
9.9 KiB
TypeScript

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";
import {
isNetworkError,
isOnlineOnly,
newIdempotencyKey,
OfflineUnavailableError,
queueWrite,
} from "@/lib/offline/offline-write";
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. */
export interface WriteOptions {
/** Reused as the `Idempotency-Key` header so the server de-duplicates retries. */
idempotencyKey?: string;
/**
* Offline behavior:
* - undefined / "queue": auto-queue on offline/network failure and return an
* optimistic value (unless the URL is online-only → throws).
* - "reject": never queue — throw OfflineUnavailableError when offline.
* - "manual": caller handles offline itself; never auto-queue (POS order submit).
*/
offline?: "queue" | "reject" | "manual";
}
async function rawWrite<T>(
method: "POST" | "PUT" | "PATCH" | "DELETE",
url: string,
body: unknown,
key: string
): Promise<T> {
const config = { headers: { "Idempotency-Key": key } };
let data: ApiResponse<T>;
switch (method) {
case "POST":
({ data } = await api.post<ApiResponse<T>>(url, body, config));
break;
case "PUT":
({ data } = await api.put<ApiResponse<T>>(url, body, config));
break;
case "PATCH":
({ data } = await api.patch<ApiResponse<T>>(url, body, config));
break;
case "DELETE":
({ data } = await api.delete<ApiResponse<T>>(url, config));
break;
}
if (method === "DELETE") {
if (!data.success) {
throw new ApiClientError(data.error?.code ?? "REQUEST_FAILED", data.error?.message ?? "Request failed");
}
return undefined as T;
}
if (!data.success || data.data === undefined) {
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed",
data.data
);
}
return data.data;
}
async function doWrite<T>(
method: "POST" | "PUT" | "PATCH" | "DELETE",
url: string,
body: unknown,
opts?: WriteOptions
): Promise<T> {
const manual = opts?.offline === "manual";
const key = opts?.idempotencyKey ?? newIdempotencyKey();
const onlineOnly = opts?.offline === "reject" || isOnlineOnly(url);
const offline = typeof navigator !== "undefined" && !navigator.onLine;
// Already offline: queue (or reject online-only) without attempting the network.
if (offline && !manual) {
if (onlineOnly) throw new OfflineUnavailableError();
return (await queueWrite(method, url, body, key)) as T;
}
try {
return await rawWrite<T>(method, url, body, key);
} catch (err) {
// A genuine network failure (no response) → queue and return optimistic.
// Real server/validation errors and online-only endpoints still throw.
if (!manual && !onlineOnly && isNetworkError(err)) {
return (await queueWrite(method, url, body, key)) as T;
}
throw err;
}
}
export async function apiPost<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
return doWrite<T>("POST", url, body, opts);
}
export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
return doWrite<T>("PUT", url, body, opts);
}
export async function apiPatch<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
return doWrite<T>("PATCH", url, body, opts);
}
export async function apiDelete(url: string, opts?: WriteOptions): Promise<void> {
await doWrite<void>("DELETE", url, undefined, opts);
}
/** 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}`}`;
}