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:
@@ -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(),
|
||||
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<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