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
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:
@@ -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<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.post<ApiResponse<T>>(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<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||
const { data } = await api.post<ApiResponse<T>>(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<T, B = unknown>(url: string, body?: B): Promise<T>
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.put<ApiResponse<T>>(url, body);
|
||||
export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||
const { data } = await api.put<ApiResponse<T>>(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<T, B = unknown>(url: string, body?: B): Promise<T>
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.patch<ApiResponse<T>>(url, body);
|
||||
export async function apiPatch<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||
const { data } = await api.patch<ApiResponse<T>>(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<T, B = unknown>(url: string, body?: B): Promise<T
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiDelete(url: string): Promise<void> {
|
||||
const { data } = await api.delete<ApiResponse<unknown>>(url);
|
||||
export async function apiDelete(url: string, opts?: WriteOptions): Promise<void> {
|
||||
const { data } = await api.delete<ApiResponse<unknown>>(url, writeConfig(opts));
|
||||
if (!data.success) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
|
||||
@@ -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<IDBDatabase> {
|
||||
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<void> {
|
||||
// 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<OutboxOp, "attempts" | "status">
|
||||
): Promise<void> {
|
||||
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<OutboxOp[]> {
|
||||
try {
|
||||
const db = await openDb();
|
||||
const ops = await new Promise<OutboxOp[]>((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<number> {
|
||||
try {
|
||||
const db = await openDb();
|
||||
return await new Promise<number>((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<void> {
|
||||
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<Pick<OutboxOp, "status" | "attempts" | "lastError">>
|
||||
): Promise<void> {
|
||||
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<Record<string, string>> {
|
||||
return (await kvGet<Record<string, string>>(ID_MAP_KEY)) ?? {};
|
||||
}
|
||||
|
||||
export async function setIdMapEntry(clientId: string, serverId: string): Promise<void> {
|
||||
const map = await getIdMap();
|
||||
map[clientId] = serverId;
|
||||
await kvSet(ID_MAP_KEY, map);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)[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<OutboxOp, "url" | "body" | "createsClientId">,
|
||||
idMap: Record<string, string>
|
||||
): { 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<unknown> {
|
||||
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<DrainResult> {
|
||||
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<OutboxOp[]> {
|
||||
const ops = await getOutboxOps();
|
||||
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
|
||||
}
|
||||
@@ -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 {
|
||||
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 apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, 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 apiPost(
|
||||
`/api/cafes/${cafeId}/orders/${orderId}/items`,
|
||||
body as Record<string, 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(),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
await removeQueueItem(item.id);
|
||||
} catch {
|
||||
return false;
|
||||
// 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();
|
||||
|
||||
@@ -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<SubmitOrderCart["getPendingLines"]>
|
||||
) {
|
||||
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<void> {
|
||||
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<Order> {
|
||||
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(),
|
||||
type: isAddToExisting ? "add_items" : "create_order",
|
||||
cafeId: params.cafeId,
|
||||
targetOrderId: isAddToExisting ? params.cart.activeOrderId : null,
|
||||
payload: isAddToExisting
|
||||
? {
|
||||
cafeId: params.cafeId,
|
||||
orderId: params.cart.activeOrderId!,
|
||||
idempotencyKey,
|
||||
method: "POST",
|
||||
url: `/api/cafes/${cafeId}/orders/${activeId}/items`,
|
||||
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(),
|
||||
entityType: "order_items",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
await refreshQueueBadge();
|
||||
return buildLocalOrder(params, cartItems, activeId);
|
||||
}
|
||||
|
||||
// Update global queue count
|
||||
const count = await getQueueCount();
|
||||
useSyncQueueStore.getState().setQueueCount(count);
|
||||
|
||||
return buildLocalOrder(params, cartItems);
|
||||
// Create a brand-new order. createsClientId lets later add-items ops remap.
|
||||
const localOrderId = newLocalId();
|
||||
await enqueueOutboxOp({
|
||||
id: newLocalId(),
|
||||
idempotencyKey,
|
||||
method: "POST",
|
||||
url: `/api/cafes/${cafeId}/orders`,
|
||||
body: buildCreateBody(params, pending),
|
||||
entityType: "order",
|
||||
createsClientId: localOrderId,
|
||||
idField: "id",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
await refreshQueueBadge();
|
||||
return buildLocalOrder(params, cartItems, localOrderId);
|
||||
}
|
||||
|
||||
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||
@@ -145,47 +179,45 @@ export async function submitOrderToApi({
|
||||
reservationId,
|
||||
cartItems = [],
|
||||
}: SubmitOrderParams): Promise<Order> {
|
||||
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<Order> => {
|
||||
if (cart.activeOrderId && !cart.activeOrderId.startsWith("local_")) {
|
||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, {
|
||||
items: pending,
|
||||
});
|
||||
}
|
||||
return apiPost<Order>(`/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<Order>(
|
||||
`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`,
|
||||
{ items: pending },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
return await apiPost<Order>(
|
||||
`/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_");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user