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
@@ -0,0 +1,120 @@
/**
* 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;
}
@@ -105,6 +105,14 @@ export function useOfflineSync() {
}, [refreshCount, setSyncing, queryClient]);
useEffect(() => {
// Ask the browser to keep our IndexedDB (outbox + cache) from being evicted
// under storage pressure, so unsynced writes survive.
if (typeof navigator !== "undefined" && navigator.storage?.persist) {
void navigator.storage.persisted().then((granted) => {
if (!granted) void navigator.storage.persist();
});
}
void (async () => {
await migrateLegacyQueue();
await refreshCount();