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
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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user