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
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.
149 lines
4.7 KiB
TypeScript
149 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
|
import {
|
|
enqueueOutboxOp,
|
|
getAllQueueItems,
|
|
getOutboxCount,
|
|
getQueueCount,
|
|
removeQueueItem,
|
|
} from "@/lib/offline/offline-db";
|
|
import { drainOutbox } from "@/lib/offline/outbox";
|
|
|
|
function newId(prefix: string): string {
|
|
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
/**
|
|
* One-time migration of any items left in the legacy POS `order_queue` into the
|
|
* generic outbox, so orders queued before this release still sync. Best-effort.
|
|
*/
|
|
async function migrateLegacyQueue(): Promise<void> {
|
|
let legacy: Awaited<ReturnType<typeof getAllQueueItems>> = [];
|
|
try {
|
|
legacy = await getAllQueueItems();
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const item of legacy) {
|
|
try {
|
|
if (item.type === "create_order") {
|
|
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
|
await enqueueOutboxOp({
|
|
id: newId("op"),
|
|
idempotencyKey: newId("idem"),
|
|
method: "POST",
|
|
url: `/api/cafes/${cafeId}/orders`,
|
|
body,
|
|
entityType: "order",
|
|
idField: "id",
|
|
createdAt: Date.parse(item.createdAt) || Date.now(),
|
|
});
|
|
} else if (item.type === "add_items") {
|
|
const { cafeId, orderId, body } = item.payload as {
|
|
cafeId: string;
|
|
orderId: string;
|
|
body: unknown;
|
|
};
|
|
await enqueueOutboxOp({
|
|
id: newId("op"),
|
|
idempotencyKey: newId("idem"),
|
|
method: "POST",
|
|
url: `/api/cafes/${cafeId}/orders/${orderId}/items`,
|
|
body,
|
|
entityType: "order_items",
|
|
createdAt: Date.parse(item.createdAt) || Date.now(),
|
|
});
|
|
}
|
|
await removeQueueItem(item.id);
|
|
} catch {
|
|
// leave the legacy item in place; we'll try again next mount
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mount once in the app shell to:
|
|
* - migrate any legacy queued orders into the outbox,
|
|
* - keep the pending-count badge and online flag in sync,
|
|
* - drain the outbox when back online or the tab regains focus,
|
|
* - refresh server data once writes have synced.
|
|
*/
|
|
export function useOfflineSync() {
|
|
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
|
|
const queryClient = useQueryClient();
|
|
const syncLock = useRef(false);
|
|
|
|
const refreshCount = useCallback(async () => {
|
|
const n = (await getOutboxCount()) + (await getQueueCount());
|
|
setQueueCount(n);
|
|
return n;
|
|
}, [setQueueCount]);
|
|
|
|
const syncQueue = useCallback(async () => {
|
|
if (syncLock.current) return;
|
|
if (typeof navigator !== "undefined" && !navigator.onLine) return;
|
|
|
|
syncLock.current = true;
|
|
setSyncing(true);
|
|
try {
|
|
const result = await drainOutbox();
|
|
if (result.sent > 0) {
|
|
// Replace optimistic local data with the authoritative server state.
|
|
await queryClient.invalidateQueries();
|
|
}
|
|
} finally {
|
|
syncLock.current = false;
|
|
setSyncing(false);
|
|
await refreshCount();
|
|
}
|
|
}, [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();
|
|
// Drain anything pending if we mounted already online.
|
|
if (typeof navigator === "undefined" || navigator.onLine) void syncQueue();
|
|
})();
|
|
|
|
const handleOnline = () => {
|
|
setOnline(true);
|
|
void syncQueue();
|
|
};
|
|
const handleOffline = () => setOnline(false);
|
|
|
|
setOnline(typeof navigator !== "undefined" ? navigator.onLine : true);
|
|
window.addEventListener("online", handleOnline);
|
|
window.addEventListener("offline", handleOffline);
|
|
|
|
const handleVisibility = () => {
|
|
if (document.visibilityState === "visible" && navigator.onLine) {
|
|
void syncQueue();
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibility);
|
|
|
|
return () => {
|
|
window.removeEventListener("online", handleOnline);
|
|
window.removeEventListener("offline", handleOffline);
|
|
document.removeEventListener("visibilitychange", handleVisibility);
|
|
};
|
|
}, [syncQueue, setOnline, refreshCount]);
|
|
|
|
return { syncQueue, refreshCount };
|
|
}
|