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
+24 -10
View File
@@ -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");