/** * Generic offline durability for the central API client. When a write happens * while offline (or fails with a network error), it is enqueued in the outbox * and an optimistic value is returned, so no write is ever lost — instead of the * mutation throwing. The online path is unchanged apart from an idempotency key. * * A small set of endpoints are *online-only* (payments, billing, auth, SMS): these * must never be queued — they throw {@link OfflineUnavailableError} when offline so * the UI can tell the user to reconnect. */ import { enqueueOutboxOp, getOutboxCount, getQueueCount, type OutboxMethod, } from "@/lib/offline/offline-db"; import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; /** Endpoints that require a live connection and must NOT be queued offline. */ const ONLINE_ONLY: RegExp[] = [ /\/api\/auth\//, // login / refresh / register / OTP /\/api\/billing\b/, // checkout / verify / payment gateway /\/payments?\b/, // taking payment against an order/shift /\/api\/sms\b/, // sending SMS now / campaigns /\/send-sms\b/, /\/export\b/, // server-computed exports ]; export function isOnlineOnly(url: string): boolean { return ONLINE_ONLY.some((re) => re.test(url)); } export class OfflineUnavailableError extends Error { readonly code = "OFFLINE_UNAVAILABLE"; constructor(message = "This action needs an internet connection.") { super(message); this.name = "OfflineUnavailableError"; } } export function isNetworkError(err: unknown): boolean { if (err instanceof TypeError) { const msg = err.message.toLowerCase(); return ( msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("load failed") || msg.includes("network request failed") ); } const ax = err as { isAxiosError?: boolean; response?: unknown }; return !!ax?.isAxiosError && !ax.response; } export function newIdempotencyKey(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; } export function newLocalId(): string { return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } /** Best-effort entity kind from a URL (last non-id path segment). */ export function entityTypeFromUrl(url: string): string { const path = (url.split("?")[0] ?? "").replace(/^\/api\//, ""); const segs = path.split("/").filter(Boolean); for (let i = segs.length - 1; i >= 0; i--) { const s = segs[i]; const looksLikeId = /^[0-9a-f]{16,}$/i.test(s) || s.startsWith("local_"); if (!looksLikeId) return s; } return segs[0] ?? "entity"; } async function refreshBadge(): Promise { const n = (await getOutboxCount()) + (await getQueueCount()); useSyncQueueStore.getState().setQueueCount(n); } /** * Enqueue a write to the outbox and synthesize an optimistic return value. * POST → treated as a create (local id, remappable later); PUT/PATCH → echo the * body; DELETE → void. */ export async function queueWrite( method: OutboxMethod, url: string, body: unknown, idempotencyKey: string ): Promise { let createsClientId: string | undefined; let optimistic: unknown; if (method === "POST") { createsClientId = newLocalId(); optimistic = body && typeof body === "object" ? { id: createsClientId, ...(body as Record) } : { id: createsClientId }; } else if (method === "DELETE") { optimistic = undefined; } else { optimistic = body && typeof body === "object" ? { ...(body as Record) } : body; } await enqueueOutboxOp({ id: newLocalId(), idempotencyKey, method, url, body, entityType: entityTypeFromUrl(url), createsClientId, idField: "id", createdAt: Date.now(), }); await refreshBadge(); return optimistic; }