feat(dashboard/offline): generic idempotent outbox + ID remapping
CI/CD / CI · API (dotnet build + test) (push) Successful in 48s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 53s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m12s

Completes offline Phase 1 (frontend). Generalises the POS-orders-only queue into
a reusable write engine and fixes the two correctness bugs in the old path.

- offline-db: generic `outbox` store (DB v3, order_queue/kv preserved) with
  enqueue/list/update/remove + a persisted client→server id map.
- outbox.ts: drains in causal order — remaps local_* ids to server ids (blocking
  an op until its creator syncs), sends each op with its idempotency key, and
  classifies failures (offline → stop; 5xx / in-progress → retry; 4xx → poison
  after 5 attempts). remap/blocked logic validated against representative cases.
- client: apiPost/Put/Patch/Delete take an optional idempotencyKey →
  `Idempotency-Key` header; ApiClientError now carries HTTP status.
- submit-order: generates ONE idempotency key per submit, used for both the
  online attempt and the queued replay → server de-dups (no more double-create);
  offline create carries createsClientId so a later add-items remaps onto the
  real order instead of spawning a second order.
- use-offline-sync: drains the outbox, one-time migrates legacy order_queue
  items, invalidates queries after a successful sync.

tsc + production build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 18:19:29 +03:30
parent f4583f5169
commit 3b468b48d9
5 changed files with 479 additions and 122 deletions
@@ -1,87 +1,117 @@
"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,
markQueueItemFailed,
} from "@/lib/offline/offline-db";
import { apiPost } from "@/lib/api/client";
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)}`;
}
/**
* Processes one queued item and returns whether it succeeded.
* 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 processItem(item: Awaited<ReturnType<typeof getAllQueueItems>>[number]): Promise<boolean> {
async function migrateLegacyQueue(): Promise<void> {
let legacy: Awaited<ReturnType<typeof getAllQueueItems>> = [];
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await apiPost(
`/api/cafes/${cafeId}/orders/${orderId}/items`,
body as Record<string, unknown>
);
}
return true;
legacy = await getAllQueueItems();
} catch {
return false;
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
}
}
}
/**
* Call this hook once in the app shell to:
* - Load initial queue count from IndexedDB on mount
* - Listen to online/offline events
* - Auto-sync when back online or tab becomes visible
* 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 getQueueCount();
const n = (await getOutboxCount()) + (await getQueueCount());
setQueueCount(n);
return n;
}, [setQueueCount]);
const syncQueue = useCallback(async () => {
if (syncLock.current) return;
if (!navigator.onLine) return;
const count = await refreshCount();
if (count === 0) return;
if (typeof navigator !== "undefined" && !navigator.onLine) return;
syncLock.current = true;
setSyncing(true);
try {
const items = await getAllQueueItems();
for (const item of items) {
if (item.status === "failed" && item.retries >= 3) continue; // give up after 3
const ok = await processItem(item);
if (ok) {
await removeQueueItem(item.id);
} else {
await markQueueItemFailed(item.id);
}
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]);
}, [refreshCount, setSyncing, queryClient]);
useEffect(() => {
// Load initial count
void refreshCount();
void (async () => {
await migrateLegacyQueue();
await refreshCount();
// Drain anything pending if we mounted already online.
if (typeof navigator === "undefined" || navigator.onLine) void syncQueue();
})();
// Track online state
const handleOnline = () => {
setOnline(true);
void syncQueue();
@@ -92,7 +122,6 @@ export function useOfflineSync() {
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Sync when tab regains focus
const handleVisibility = () => {
if (document.visibilityState === "visible" && navigator.onLine) {
void syncQueue();