Files
meezi/web/dashboard/src/lib/offline/offline-write.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

121 lines
3.8 KiB
TypeScript

/**
* 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<void> {
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<unknown> {
let createsClientId: string | undefined;
let optimistic: unknown;
if (method === "POST") {
createsClientId = newLocalId();
optimistic =
body && typeof body === "object"
? { id: createsClientId, ...(body as Record<string, unknown>) }
: { id: createsClientId };
} else if (method === "DELETE") {
optimistic = undefined;
} else {
optimistic = body && typeof body === "object" ? { ...(body as Record<string, unknown>) } : body;
}
await enqueueOutboxOp({
id: newLocalId(),
idempotencyKey,
method,
url,
body,
entityType: entityTypeFromUrl(url),
createsClientId,
idField: "id",
createdAt: Date.now(),
});
await refreshBadge();
return optimistic;
}