feat(offline): make every dashboard write durable offline (P2–P5)
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

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.
This commit is contained in:
soroush.asadi
2026-06-02 18:34:54 +03:30
parent 3b468b48d9
commit eb165db182
10 changed files with 273 additions and 63 deletions
+86 -29
View File
@@ -5,6 +5,13 @@ import 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";
@@ -140,51 +147,101 @@ export class ApiClientError extends Error {
}
}
/** 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). */
/** 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";
}
function writeConfig(opts?: WriteOptions) {
if (!opts?.idempotencyKey) return undefined;
return { headers: { "Idempotency-Key": opts.idempotencyKey } };
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> {
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;
return doWrite<T>("POST", url, body, opts);
}
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;
return doWrite<T>("PUT", url, body, opts);
}
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;
return doWrite<T>("PATCH", url, body, opts);
}
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");
}
await doWrite<void>("DELETE", url, undefined, opts);
}
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */