From 3b468b48d956a7aa703b71daee872747c45cfeed Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 18:19:29 +0330 Subject: [PATCH] feat(dashboard/offline): generic idempotent outbox + ID remapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/dashboard/src/lib/api/client.ts | 34 ++-- web/dashboard/src/lib/offline/offline-db.ts | 117 +++++++++++- web/dashboard/src/lib/offline/outbox.ts | 167 +++++++++++++++++ .../src/lib/offline/use-offline-sync.ts | 113 +++++++----- web/dashboard/src/lib/pos/submit-order.ts | 170 +++++++++++------- 5 files changed, 479 insertions(+), 122 deletions(-) create mode 100644 web/dashboard/src/lib/offline/outbox.ts diff --git a/web/dashboard/src/lib/api/client.ts b/web/dashboard/src/lib/api/client.ts index 1758fbc..101fa1d 100644 --- a/web/dashboard/src/lib/api/client.ts +++ b/web/dashboard/src/lib/api/client.ts @@ -79,7 +79,7 @@ api.interceptors.response.use( const apiError = error.response?.data?.error; if (apiError?.code) { - return Promise.reject(new ApiClientError(apiError.code, apiError.message)); + return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status)); } if (status === 401 && typeof window !== "undefined") { const path = window.location.pathname; @@ -131,15 +131,29 @@ export class ApiClientError extends Error { public readonly code: string, message: string, /** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */ - public readonly payload?: unknown + public readonly payload?: unknown, + /** HTTP status, when known — lets callers (e.g. the outbox) tell 5xx (retry) from 4xx (give up). */ + public readonly status?: number ) { super(message); this.name = "ApiClientError"; } } -export async function apiPost(url: string, body?: B): Promise { - const { data } = await api.post>(url, body); +/** 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). */ +export interface WriteOptions { + idempotencyKey?: string; +} + +function writeConfig(opts?: WriteOptions) { + if (!opts?.idempotencyKey) return undefined; + return { headers: { "Idempotency-Key": opts.idempotencyKey } }; +} + +export async function apiPost(url: string, body?: B, opts?: WriteOptions): Promise { + const { data } = await api.post>(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); @@ -147,8 +161,8 @@ export async function apiPost(url: string, body?: B): Promise return data.data; } -export async function apiPut(url: string, body?: B): Promise { - const { data } = await api.put>(url, body); +export async function apiPut(url: string, body?: B, opts?: WriteOptions): Promise { + const { data } = await api.put>(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"); @@ -156,8 +170,8 @@ export async function apiPut(url: string, body?: B): Promise return data.data; } -export async function apiPatch(url: string, body?: B): Promise { - const { data } = await api.patch>(url, body); +export async function apiPatch(url: string, body?: B, opts?: WriteOptions): Promise { + const { data } = await api.patch>(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"); @@ -165,8 +179,8 @@ export async function apiPatch(url: string, body?: B): Promise { - const { data } = await api.delete>(url); +export async function apiDelete(url: string, opts?: WriteOptions): Promise { + const { data } = await api.delete>(url, writeConfig(opts)); if (!data.success) { const code = data.error?.code ?? "REQUEST_FAILED"; throw new ApiClientError(code, data.error?.message ?? "Request failed"); diff --git a/web/dashboard/src/lib/offline/offline-db.ts b/web/dashboard/src/lib/offline/offline-db.ts index f0a0214..6a30f71 100644 --- a/web/dashboard/src/lib/offline/offline-db.ts +++ b/web/dashboard/src/lib/offline/offline-db.ts @@ -22,10 +22,13 @@ export type OfflineQueueItem = { }; const DB_NAME = "meezi_pos_offline"; -const DB_VERSION = 2; +const DB_VERSION = 3; +/** Legacy POS-orders-only queue (kept for one-time migration into the outbox). */ const STORE = "order_queue"; /** Generic key-value store (used to persist the React Query cache for offline reads). */ const KV_STORE = "kv"; +/** Generic write outbox: any mutating request, replayed with idempotency + id remap. */ +const OUTBOX_STORE = "outbox"; let _db: IDBDatabase | null = null; @@ -41,6 +44,9 @@ function openDb(): Promise { if (!db.objectStoreNames.contains(KV_STORE)) { db.createObjectStore(KV_STORE); } + if (!db.objectStoreNames.contains(OUTBOX_STORE)) { + db.createObjectStore(OUTBOX_STORE, { keyPath: "id" }); + } }; req.onsuccess = () => { _db = req.result; @@ -161,3 +167,112 @@ export async function kvDelete(key: string): Promise { // ignore } } + +// ─── Generic write outbox ────────────────────────────────────────────────────── + +export type OutboxMethod = "POST" | "PUT" | "PATCH" | "DELETE"; + +export type OutboxOp = { + /** Local op id (primary key). */ + id: string; + /** Stable Idempotency-Key sent on every send attempt for this op. */ + idempotencyKey: string; + method: OutboxMethod; + /** Request URL; may embed a local id (local_*) to be remapped after its creator syncs. */ + url: string; + body?: unknown; + /** Coarse entity kind, for conflict policy + UI grouping (e.g. "order", "menu_item"). */ + entityType: string; + /** The local id this op creates, if any — enables remapping later ops that reference it. */ + createsClientId?: string; + /** Dotted path to the new server id in the response data (default "id"). */ + idField?: string; + createdAt: number; + attempts: number; + status: "pending" | "failed"; + lastError?: string; +}; + +export async function enqueueOutboxOp( + op: Omit +): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(OUTBOX_STORE, "readwrite"); + tx.objectStore(OUTBOX_STORE).put({ ...op, attempts: 0, status: "pending" }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +/** All queued ops, oldest first (insertion / causal order). */ +export async function getOutboxOps(): Promise { + try { + const db = await openDb(); + const ops = await new Promise((resolve, reject) => { + const tx = db.transaction(OUTBOX_STORE, "readonly"); + const req = tx.objectStore(OUTBOX_STORE).getAll(); + req.onsuccess = () => resolve(req.result as OutboxOp[]); + req.onerror = () => reject(req.error); + }); + return ops.sort((a, b) => a.createdAt - b.createdAt); + } catch { + return []; + } +} + +export async function getOutboxCount(): Promise { + try { + const db = await openDb(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(OUTBOX_STORE, "readonly"); + const req = tx.objectStore(OUTBOX_STORE).count(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } catch { + return 0; + } +} + +export async function removeOutboxOp(id: string): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(OUTBOX_STORE, "readwrite"); + tx.objectStore(OUTBOX_STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function updateOutboxOp( + id: string, + patch: Partial> +): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(OUTBOX_STORE, "readwrite"); + const store = tx.objectStore(OUTBOX_STORE); + const getReq = store.get(id); + getReq.onsuccess = () => { + const op = getReq.result as OutboxOp | undefined; + if (op) store.put({ ...op, ...patch }); + }; + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +// ─── client→server id map (persisted across reloads) ─────────────────────────── + +const ID_MAP_KEY = "outbox_id_map"; + +export async function getIdMap(): Promise> { + return (await kvGet>(ID_MAP_KEY)) ?? {}; +} + +export async function setIdMapEntry(clientId: string, serverId: string): Promise { + const map = await getIdMap(); + map[clientId] = serverId; + await kvSet(ID_MAP_KEY, map); +} diff --git a/web/dashboard/src/lib/offline/outbox.ts b/web/dashboard/src/lib/offline/outbox.ts new file mode 100644 index 0000000..5cc89ae --- /dev/null +++ b/web/dashboard/src/lib/offline/outbox.ts @@ -0,0 +1,167 @@ +/** + * Generic offline write engine. + * + * Every offline write is recorded as an {@link OutboxOp} carrying a stable + * idempotency key. On reconnect the outbox is drained in causal (insertion) + * order: + * - local ids (local_*) created by earlier ops are remapped to their real + * server ids before an op that references them is sent; + * - each op is sent with its idempotency key, so a replay after a lost response + * is de-duplicated by the server instead of creating a duplicate; + * - failures are classified: offline → stop; server 5xx / in-progress → + * retry next pass; client 4xx → count an attempt and poison after MAX. + */ +import { isAxiosError } from "axios"; +import { + apiDelete, + apiPatch, + apiPost, + apiPut, + ApiClientError, + type WriteOptions, +} from "@/lib/api/client"; +import { + getIdMap, + getOutboxOps, + removeOutboxOp, + setIdMapEntry, + updateOutboxOp, + type OutboxOp, +} from "@/lib/offline/offline-db"; + +const MAX_ATTEMPTS = 5; +/** Matches local placeholder ids like `local_1717…_a1b2c3`. */ +const LOCAL_ID_RE = /local_[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*/g; + +function getByPath(obj: unknown, path: string): string | undefined { + let cur: unknown = obj; + for (const part of path.split(".")) { + if (cur == null || typeof cur !== "object") return undefined; + cur = (cur as Record)[part]; + } + return typeof cur === "string" ? cur : undefined; +} + +/** + * Replace known local ids in the op's url/body with their server ids. Returns + * `blocked: true` if it still references an unresolved local id (its creator + * hasn't synced yet) other than the id this op itself creates. + */ +export function remapReferences( + op: Pick, + idMap: Record +): { url: string; body: unknown; blocked: boolean } { + let url = op.url; + let bodyStr = op.body !== undefined ? JSON.stringify(op.body) : ""; + + for (const [clientId, serverId] of Object.entries(idMap)) { + if (url.includes(clientId)) url = url.split(clientId).join(serverId); + if (bodyStr && bodyStr.includes(clientId)) bodyStr = bodyStr.split(clientId).join(serverId); + } + + const remaining = `${url} ${bodyStr}`.match(LOCAL_ID_RE) ?? []; + const unresolved = remaining.filter((id) => id !== op.createsClientId); + + return { + url, + body: bodyStr !== "" ? JSON.parse(bodyStr) : op.body, + blocked: unresolved.length > 0, + }; +} + +async function sendOp(op: OutboxOp, url: string, body: unknown): Promise { + const opts: WriteOptions = { idempotencyKey: op.idempotencyKey }; + switch (op.method) { + case "POST": + return apiPost(url, body, opts); + case "PUT": + return apiPut(url, body, opts); + case "PATCH": + return apiPatch(url, body, opts); + case "DELETE": + await apiDelete(url, opts); + return undefined; + } +} + +type Disposition = "offline" | "transient" | "permanent"; + +function classify(err: unknown): Disposition { + if (err instanceof ApiClientError) { + if (err.code === "IDEMPOTENCY_IN_PROGRESS") return "transient"; // another tab/pass owns it + if (err.status !== undefined && err.status >= 500) return "transient"; + return "permanent"; // validation / 4xx — retrying the same payload won't help + } + if (isAxiosError(err)) { + if (!err.response) return "offline"; // network down + if (err.response.status >= 500) return "transient"; + return "permanent"; + } + return "permanent"; +} + +function errMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +export type DrainResult = { sent: number; remaining: number; ran: boolean }; + +let draining = false; + +/** Drain the outbox once, in causal order. Never throws. */ +export async function drainOutbox(): Promise { + const isOffline = typeof navigator !== "undefined" && !navigator.onLine; + if (draining || isOffline) { + return { sent: 0, remaining: (await getOutboxOps()).length, ran: false }; + } + + draining = true; + let sent = 0; + try { + const idMap = await getIdMap(); + const ops = await getOutboxOps(); + + for (const op of ops) { + if (op.status === "failed" && op.attempts >= MAX_ATTEMPTS) continue; // poisoned + + const { url, body, blocked } = remapReferences(op, idMap); + if (blocked) continue; // a dependency hasn't synced yet; revisit next pass + + try { + const result = await sendOp(op, url, body); + if (op.createsClientId) { + const serverId = getByPath(result, op.idField ?? "id"); + if (serverId) { + idMap[op.createsClientId] = serverId; + await setIdMapEntry(op.createsClientId, serverId); + } + } + await removeOutboxOp(op.id); + sent++; + } catch (err) { + const disposition = classify(err); + if (disposition === "offline") break; // stop the whole pass; resume when online + if (disposition === "transient") { + await updateOutboxOp(op.id, { lastError: errMessage(err) }); // retry, don't burn an attempt + continue; + } + await updateOutboxOp(op.id, { + status: "failed", + attempts: op.attempts + 1, + lastError: errMessage(err), + }); + } + } + } finally { + draining = false; + } + + return { sent, remaining: (await getOutboxOps()).length, ran: true }; +} + +/** Ops that exhausted their retries and need user attention. */ +export async function getPoisonedOps(): Promise { + const ops = await getOutboxOps(); + return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS); +} diff --git a/web/dashboard/src/lib/offline/use-offline-sync.ts b/web/dashboard/src/lib/offline/use-offline-sync.ts index 9c0ad98..dbbff23 100644 --- a/web/dashboard/src/lib/offline/use-offline-sync.ts +++ b/web/dashboard/src/lib/offline/use-offline-sync.ts @@ -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>[number]): Promise { +async function migrateLegacyQueue(): Promise { + let legacy: Awaited> = []; 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); - } 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 - ); - } - 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(); diff --git a/web/dashboard/src/lib/pos/submit-order.ts b/web/dashboard/src/lib/pos/submit-order.ts index 47177f1..46dfbc8 100644 --- a/web/dashboard/src/lib/pos/submit-order.ts +++ b/web/dashboard/src/lib/pos/submit-order.ts @@ -2,7 +2,7 @@ import { apiPost } from "@/lib/api/client"; import type { Order, OrderItemLine } from "@/lib/api/types"; import type { CartItem } from "@/lib/stores/cart.store"; import { iranMobileForApi } from "@/lib/phone"; -import { enqueueOfflineItem, getQueueCount } from "@/lib/offline/offline-db"; +import { enqueueOutboxOp, getOutboxCount, getQueueCount } from "@/lib/offline/offline-db"; import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; export type SubmitOrderCart = { @@ -24,7 +24,7 @@ export type SubmitOrderParams = { cartItems?: CartItem[]; }; -// ─── Offline helpers ────────────────────────────────────────────────────────── +// ─── Helpers ──────────────────────────────────────────────────────────────── function isNetworkError(err: unknown): boolean { if (err instanceof TypeError) { @@ -36,6 +36,9 @@ function isNetworkError(err: unknown): boolean { msg.includes("network request failed") ); } + // axios network errors surface as an Error with code ERR_NETWORK and no response. + const ax = err as { isAxiosError?: boolean; response?: unknown }; + if (ax?.isAxiosError && !ax.response) return true; return false; } @@ -43,13 +46,36 @@ function newLocalId(): string { return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } -/** Build a synthetic Order that keeps the POS cart functional while offline */ -function buildLocalOrder( +/** A stable idempotency key used for BOTH the online attempt and any queued + * replay of the same submit, so the server de-duplicates them. */ +function newIdempotencyKey(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; +} + +/** Body for a create-order POST. */ +function buildCreateBody( params: SubmitOrderParams, - cartItems: CartItem[] -): Order { + pending: ReturnType +) { + const { cart, orderBranchId, reservationId } = params; + return { + orderType: "DineIn", + branchId: orderBranchId, + tableId: cart.tableId ?? undefined, + reservationId: reservationId ?? undefined, + guestName: cart.guestName.trim() || undefined, + guestPhone: iranMobileForApi(cart.guestPhone), + customerId: cart.customerId ?? undefined, + couponId: cart.appliedCoupon?.id, + items: pending, + }; +} + +/** Build a synthetic Order so the POS stays usable offline. Uses the supplied + * id so it matches the outbox op's createsClientId (enabling later remap). */ +function buildLocalOrder(params: SubmitOrderParams, cartItems: CartItem[], orderId: string): Order { const pending = params.cart.getPendingLines(); - const localId = newLocalId(); const items: OrderItemLine[] = pending.map((p) => { const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId); @@ -69,7 +95,7 @@ function buildLocalOrder( const total = subtotal + taxTotal; return { - id: localId, + id: orderId, cafeId: params.cafeId, branchId: params.orderBranchId, tableId: params.cart.tableId ?? undefined, @@ -90,50 +116,58 @@ function buildLocalOrder( }; } +async function refreshQueueBadge(): Promise { + const count = (await getOutboxCount()) + (await getQueueCount()); + useSyncQueueStore.getState().setQueueCount(count); +} + +/** + * Queue the write and return a local mock order. Two cases: + * - create: enqueue POST /orders with a fresh local id as createsClientId; + * - add items: enqueue POST /orders/{id}/items. {id} may be a local id — the + * outbox blocks then remaps it once the create syncs. + */ async function queueAndBuildLocalOrder( params: SubmitOrderParams, - cartItems: CartItem[] + cartItems: CartItem[], + idempotencyKey: string ): Promise { - const pending = params.cart.getPendingLines(); + const { cafeId, cart } = params; + const pending = cart.getPendingLines(); if (pending.length === 0) throw new Error("nothing pending"); - const isAddToExisting = - !!params.cart.activeOrderId && - !params.cart.activeOrderId.startsWith("local_"); + const activeId = cart.activeOrderId; - await enqueueOfflineItem({ + if (activeId) { + // Add items to an existing order (real server id, or a not-yet-synced local id). + await enqueueOutboxOp({ + id: newLocalId(), + idempotencyKey, + method: "POST", + url: `/api/cafes/${cafeId}/orders/${activeId}/items`, + body: { items: pending }, + entityType: "order_items", + createdAt: Date.now(), + }); + await refreshQueueBadge(); + return buildLocalOrder(params, cartItems, activeId); + } + + // Create a brand-new order. createsClientId lets later add-items ops remap. + const localOrderId = newLocalId(); + await enqueueOutboxOp({ id: newLocalId(), - type: isAddToExisting ? "add_items" : "create_order", - cafeId: params.cafeId, - targetOrderId: isAddToExisting ? params.cart.activeOrderId : null, - payload: isAddToExisting - ? { - cafeId: params.cafeId, - orderId: params.cart.activeOrderId!, - body: { items: pending }, - } - : { - cafeId: params.cafeId, - body: { - orderType: "DineIn", - branchId: params.orderBranchId, - tableId: params.cart.tableId ?? undefined, - reservationId: params.reservationId ?? undefined, - guestName: params.cart.guestName.trim() || undefined, - guestPhone: iranMobileForApi(params.cart.guestPhone), - customerId: params.cart.customerId ?? undefined, - couponId: params.cart.appliedCoupon?.id, - items: pending, - }, - }, - createdAt: new Date().toISOString(), + idempotencyKey, + method: "POST", + url: `/api/cafes/${cafeId}/orders`, + body: buildCreateBody(params, pending), + entityType: "order", + createsClientId: localOrderId, + idField: "id", + createdAt: Date.now(), }); - - // Update global queue count - const count = await getQueueCount(); - useSyncQueueStore.getState().setQueueCount(count); - - return buildLocalOrder(params, cartItems); + await refreshQueueBadge(); + return buildLocalOrder(params, cartItems, localOrderId); } // ─── Main export ────────────────────────────────────────────────────────────── @@ -145,47 +179,45 @@ export async function submitOrderToApi({ reservationId, cartItems = [], }: SubmitOrderParams): Promise { + const params: SubmitOrderParams = { cafeId, orderBranchId, cart, reservationId, cartItems }; const pending = cart.getPendingLines(); if (pending.length === 0) throw new Error("nothing pending"); - const tryOnline = async (): Promise => { - if (cart.activeOrderId && !cart.activeOrderId.startsWith("local_")) { - return apiPost(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, { - items: pending, - }); - } - return apiPost(`/api/cafes/${cafeId}/orders`, { - orderType: "DineIn", - branchId: orderBranchId, - tableId: cart.tableId ?? undefined, - reservationId: reservationId ?? undefined, - guestName: cart.guestName.trim() || undefined, - guestPhone: iranMobileForApi(cart.guestPhone), - customerId: cart.customerId ?? undefined, - couponId: cart.appliedCoupon?.id, - items: pending, - }); - }; + const idempotencyKey = newIdempotencyKey(); + const addingToLocalOrder = isLocalOrder(cart.activeOrderId); - // Try online first - if (navigator.onLine) { + // Fast path: online, and either a new order or adding to a real server order. + // (Adding to a still-local order must be queued so the outbox can remap its id.) + if (typeof navigator !== "undefined" && navigator.onLine && !addingToLocalOrder) { try { - return await tryOnline(); + if (cart.activeOrderId) { + return await apiPost( + `/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, + { items: pending }, + { idempotencyKey } + ); + } + return await apiPost( + `/api/cafes/${cafeId}/orders`, + buildCreateBody(params, pending), + { idempotencyKey } + ); } catch (err) { - // If it's a network error despite onLine flag, fall through to offline path + // Only fall back to the offline queue on a genuine network failure; a real + // server/validation error must surface. The same idempotencyKey is reused + // so the server de-dups if the failed attempt actually reached it. if (!isNetworkError(err)) throw err; } } - // Offline path: queue and return a local mock order - return queueAndBuildLocalOrder({ cafeId, orderBranchId, cart, reservationId, cartItems }, cartItems); + return queueAndBuildLocalOrder(params, cartItems, idempotencyKey); } export function orderAmountDue(order: Order): number { return Math.max(0, order.total - (order.paidAmount ?? 0)); } -/** True when the order was created locally (offline) and not yet synced */ +/** True when the order was created locally (offline) and not yet synced. */ export function isLocalOrder(orderId: string | null): boolean { return !!orderId?.startsWith("local_"); }