From eb165db1824fa914babdfbc6968b5e313769c3ea Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 18:34:54 +0330 Subject: [PATCH] =?UTF-8?q?feat(offline):=20make=20every=20dashboard=20wri?= =?UTF-8?q?te=20durable=20offline=20(P2=E2=80=93P5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the outbox engine to take the whole dashboard offline in one place instead of wiring 114 mutation sites individually. Frontend (single chokepoint = the API client): - offline-write: any write auto-queues to the outbox on offline/network failure and returns an optimistic value; the online path is unchanged apart from an Idempotency-Key header (so even online retries de-dup). entityType is derived from the URL; POSTs get a remappable local id. - client.doWrite unifies POST/PUT/PATCH/DELETE through this path. WriteOptions gains `offline: "queue" | "reject" | "manual"`. - Guardrails: auth / billing / payments / SMS / exports are online-only and throw OFFLINE_UNAVAILABLE offline rather than queueing (no queued double-charges or surprise SMS blasts). use-api-error resolves the friendly localized message (fa/en/ar). - submit-order opts out ("manual") to keep its richer local-Order mock; shared helpers de-duplicated into offline-write. - Request persistent storage on mount so unsynced writes survive eviction. Backend: - IdempotencyCleanupJob: daily purge of idempotency records older than 7 days (the table now gets a row per keyed write). Registered in Hangfire. No migration. 86 API tests pass; dashboard tsc + build clean. --- .../Extensions/ServiceCollectionExtensions.cs | 5 + src/Meezi.API/Jobs/IdempotencyCleanupJob.cs | 36 ++++++ web/dashboard/messages/ar.json | 3 +- web/dashboard/messages/en.json | 3 +- web/dashboard/messages/fa.json | 3 +- web/dashboard/src/lib/api/client.ts | 115 ++++++++++++----- .../src/lib/offline/offline-write.ts | 120 ++++++++++++++++++ .../src/lib/offline/use-offline-sync.ts | 8 ++ web/dashboard/src/lib/pos/submit-order.ts | 33 +---- web/dashboard/src/lib/use-api-error.ts | 10 +- 10 files changed, 273 insertions(+), 63 deletions(-) create mode 100644 src/Meezi.API/Jobs/IdempotencyCleanupJob.cs create mode 100644 web/dashboard/src/lib/offline/offline-write.ts diff --git a/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs b/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs index 6cc8fd9..fc73092 100644 --- a/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs +++ b/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs @@ -245,6 +245,11 @@ public static class ServiceCollectionExtensions "branch-permanent-delete", job => job.ExecuteAsync(), Cron.Hourly); + + RecurringJob.AddOrUpdate( + "idempotency-cleanup", + job => job.ExecuteAsync(), + Cron.Daily(4)); } return app; diff --git a/src/Meezi.API/Jobs/IdempotencyCleanupJob.cs b/src/Meezi.API/Jobs/IdempotencyCleanupJob.cs new file mode 100644 index 0000000..56944f9 --- /dev/null +++ b/src/Meezi.API/Jobs/IdempotencyCleanupJob.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Meezi.Infrastructure.Data; + +namespace Meezi.API.Jobs; + +/// +/// Purges old idempotency records. Keys only need to outlive realistic offline +/// gaps and client retries, so a short retention keeps the table small. +/// +public class IdempotencyCleanupJob +{ + private static readonly TimeSpan Retention = TimeSpan.FromDays(7); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public IdempotencyCleanupJob( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public async Task ExecuteAsync() + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var cutoff = DateTime.UtcNow - Retention; + var removed = await db.IdempotencyRecords + .Where(r => r.CreatedAt < cutoff) + .ExecuteDeleteAsync(); + if (removed > 0) + _logger.LogInformation("Purged {Count} idempotency records older than {Days}d", removed, Retention.TotalDays); + } +} diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index c2a67c3..ee8aad8 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -25,7 +25,8 @@ "notFound": "غير موجود", "unauthorized": "غير مصرح", "network": "خطأ في الاتصال", - "generic": "حدث خطأ. حاول مرة أخرى." + "generic": "حدث خطأ. حاول مرة أخرى.", + "OFFLINE_UNAVAILABLE": "يتطلب هذا الإجراء اتصالاً بالإنترنت. يرجى المحاولة بعد عودة الاتصال." }, "brand": { "name": "ميزي" diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 756c460..d8ccf59 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -25,7 +25,8 @@ "notFound": "Not found", "unauthorized": "Unauthorized", "network": "Network error", - "generic": "Something went wrong. Please try again." + "generic": "Something went wrong. Please try again.", + "OFFLINE_UNAVAILABLE": "This action needs an internet connection. Please try again once you are back online." }, "brand": { "name": "Meezi" diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index b2f835e..782be08 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -25,7 +25,8 @@ "notFound": "یافت نشد", "unauthorized": "دسترسی ندارید", "network": "خطای ارتباط با سرور", - "generic": "خطایی رخ داد. دوباره تلاش کنید." + "generic": "خطایی رخ داد. دوباره تلاش کنید.", + "OFFLINE_UNAVAILABLE": "برای این کار به اینترنت نیاز است. لطفاً پس از اتصال دوباره تلاش کنید." }, "brand": { "name": "میزی" diff --git a/web/dashboard/src/lib/api/client.ts b/web/dashboard/src/lib/api/client.ts index 101fa1d..85b50ac 100644 --- a/web/dashboard/src/lib/api/client.ts +++ b/web/dashboard/src/lib/api/client.ts @@ -5,6 +5,13 @@ import axios, { import type { ApiResponse, AuthTokenResponse } from "./types"; import { getOrCreateTerminalId } from "@/lib/terminal"; import { useAuthStore } from "@/lib/stores/auth.store"; +import { + isNetworkError, + isOnlineOnly, + newIdempotencyKey, + OfflineUnavailableError, + queueWrite, +} from "@/lib/offline/offline-write"; const baseURL = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208"; @@ -140,51 +147,101 @@ export class ApiClientError extends Error { } } -/** 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). */ +/** Options for mutating requests. */ export interface WriteOptions { + /** Reused as the `Idempotency-Key` header so the server de-duplicates retries. */ idempotencyKey?: string; + /** + * Offline behavior: + * - undefined / "queue": auto-queue on offline/network failure and return an + * optimistic value (unless the URL is online-only → throws). + * - "reject": never queue — throw OfflineUnavailableError when offline. + * - "manual": caller handles offline itself; never auto-queue (POS order submit). + */ + offline?: "queue" | "reject" | "manual"; } -function writeConfig(opts?: WriteOptions) { - if (!opts?.idempotencyKey) return undefined; - return { headers: { "Idempotency-Key": opts.idempotencyKey } }; +async function rawWrite( + method: "POST" | "PUT" | "PATCH" | "DELETE", + url: string, + body: unknown, + key: string +): Promise { + const config = { headers: { "Idempotency-Key": key } }; + let data: ApiResponse; + switch (method) { + case "POST": + ({ data } = await api.post>(url, body, config)); + break; + case "PUT": + ({ data } = await api.put>(url, body, config)); + break; + case "PATCH": + ({ data } = await api.patch>(url, body, config)); + break; + case "DELETE": + ({ data } = await api.delete>(url, config)); + break; + } + if (method === "DELETE") { + if (!data.success) { + throw new ApiClientError(data.error?.code ?? "REQUEST_FAILED", data.error?.message ?? "Request failed"); + } + return undefined as T; + } + if (!data.success || data.data === undefined) { + throw new ApiClientError( + data.error?.code ?? "REQUEST_FAILED", + data.error?.message ?? "Request failed", + data.data + ); + } + return data.data; +} + +async function doWrite( + method: "POST" | "PUT" | "PATCH" | "DELETE", + url: string, + body: unknown, + opts?: WriteOptions +): Promise { + const manual = opts?.offline === "manual"; + const key = opts?.idempotencyKey ?? newIdempotencyKey(); + const onlineOnly = opts?.offline === "reject" || isOnlineOnly(url); + const offline = typeof navigator !== "undefined" && !navigator.onLine; + + // Already offline: queue (or reject online-only) without attempting the network. + if (offline && !manual) { + if (onlineOnly) throw new OfflineUnavailableError(); + return (await queueWrite(method, url, body, key)) as T; + } + + try { + return await rawWrite(method, url, body, key); + } catch (err) { + // A genuine network failure (no response) → queue and return optimistic. + // Real server/validation errors and online-only endpoints still throw. + if (!manual && !onlineOnly && isNetworkError(err)) { + return (await queueWrite(method, url, body, key)) as T; + } + throw err; + } } 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); - } - return data.data; + return doWrite("POST", url, body, opts); } 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"); - } - return data.data; + return doWrite("PUT", url, body, opts); } 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"); - } - return data.data; + return doWrite("PATCH", url, body, opts); } 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"); - } + await doWrite("DELETE", url, undefined, opts); } /** GET binary response (QR PNG, Excel export, etc.) with auth headers. */ diff --git a/web/dashboard/src/lib/offline/offline-write.ts b/web/dashboard/src/lib/offline/offline-write.ts new file mode 100644 index 0000000..2cab520 --- /dev/null +++ b/web/dashboard/src/lib/offline/offline-write.ts @@ -0,0 +1,120 @@ +/** + * Generic offline durability for the central API client. When a write happens + * while offline (or fails with a network error), it is enqueued in the outbox + * and an optimistic value is returned, so no write is ever lost — instead of the + * mutation throwing. The online path is unchanged apart from an idempotency key. + * + * A small set of endpoints are *online-only* (payments, billing, auth, SMS): these + * must never be queued — they throw {@link OfflineUnavailableError} when offline so + * the UI can tell the user to reconnect. + */ +import { + enqueueOutboxOp, + getOutboxCount, + getQueueCount, + type OutboxMethod, +} from "@/lib/offline/offline-db"; +import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; + +/** Endpoints that require a live connection and must NOT be queued offline. */ +const ONLINE_ONLY: RegExp[] = [ + /\/api\/auth\//, // login / refresh / register / OTP + /\/api\/billing\b/, // checkout / verify / payment gateway + /\/payments?\b/, // taking payment against an order/shift + /\/api\/sms\b/, // sending SMS now / campaigns + /\/send-sms\b/, + /\/export\b/, // server-computed exports +]; + +export function isOnlineOnly(url: string): boolean { + return ONLINE_ONLY.some((re) => re.test(url)); +} + +export class OfflineUnavailableError extends Error { + readonly code = "OFFLINE_UNAVAILABLE"; + constructor(message = "This action needs an internet connection.") { + super(message); + this.name = "OfflineUnavailableError"; + } +} + +export function isNetworkError(err: unknown): boolean { + if (err instanceof TypeError) { + const msg = err.message.toLowerCase(); + return ( + msg.includes("failed to fetch") || + msg.includes("networkerror") || + msg.includes("load failed") || + msg.includes("network request failed") + ); + } + const ax = err as { isAxiosError?: boolean; response?: unknown }; + return !!ax?.isAxiosError && !ax.response; +} + +export function newIdempotencyKey(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; +} + +export function newLocalId(): string { + return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** Best-effort entity kind from a URL (last non-id path segment). */ +export function entityTypeFromUrl(url: string): string { + const path = (url.split("?")[0] ?? "").replace(/^\/api\//, ""); + const segs = path.split("/").filter(Boolean); + for (let i = segs.length - 1; i >= 0; i--) { + const s = segs[i]; + const looksLikeId = /^[0-9a-f]{16,}$/i.test(s) || s.startsWith("local_"); + if (!looksLikeId) return s; + } + return segs[0] ?? "entity"; +} + +async function refreshBadge(): Promise { + const n = (await getOutboxCount()) + (await getQueueCount()); + useSyncQueueStore.getState().setQueueCount(n); +} + +/** + * Enqueue a write to the outbox and synthesize an optimistic return value. + * POST → treated as a create (local id, remappable later); PUT/PATCH → echo the + * body; DELETE → void. + */ +export async function queueWrite( + method: OutboxMethod, + url: string, + body: unknown, + idempotencyKey: string +): Promise { + let createsClientId: string | undefined; + let optimistic: unknown; + + if (method === "POST") { + createsClientId = newLocalId(); + optimistic = + body && typeof body === "object" + ? { id: createsClientId, ...(body as Record) } + : { id: createsClientId }; + } else if (method === "DELETE") { + optimistic = undefined; + } else { + optimistic = body && typeof body === "object" ? { ...(body as Record) } : body; + } + + await enqueueOutboxOp({ + id: newLocalId(), + idempotencyKey, + method, + url, + body, + entityType: entityTypeFromUrl(url), + createsClientId, + idField: "id", + createdAt: Date.now(), + }); + await refreshBadge(); + return optimistic; +} diff --git a/web/dashboard/src/lib/offline/use-offline-sync.ts b/web/dashboard/src/lib/offline/use-offline-sync.ts index dbbff23..d0a9b54 100644 --- a/web/dashboard/src/lib/offline/use-offline-sync.ts +++ b/web/dashboard/src/lib/offline/use-offline-sync.ts @@ -105,6 +105,14 @@ export function useOfflineSync() { }, [refreshCount, setSyncing, queryClient]); useEffect(() => { + // Ask the browser to keep our IndexedDB (outbox + cache) from being evicted + // under storage pressure, so unsynced writes survive. + if (typeof navigator !== "undefined" && navigator.storage?.persist) { + void navigator.storage.persisted().then((granted) => { + if (!granted) void navigator.storage.persist(); + }); + } + void (async () => { await migrateLegacyQueue(); await refreshCount(); diff --git a/web/dashboard/src/lib/pos/submit-order.ts b/web/dashboard/src/lib/pos/submit-order.ts index 46dfbc8..bb17b48 100644 --- a/web/dashboard/src/lib/pos/submit-order.ts +++ b/web/dashboard/src/lib/pos/submit-order.ts @@ -3,6 +3,7 @@ import type { Order, OrderItemLine } from "@/lib/api/types"; import type { CartItem } from "@/lib/stores/cart.store"; import { iranMobileForApi } from "@/lib/phone"; import { enqueueOutboxOp, getOutboxCount, getQueueCount } from "@/lib/offline/offline-db"; +import { isNetworkError, newIdempotencyKey, newLocalId } from "@/lib/offline/offline-write"; import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; export type SubmitOrderCart = { @@ -25,33 +26,7 @@ export type SubmitOrderParams = { }; // ─── Helpers ──────────────────────────────────────────────────────────────── - -function isNetworkError(err: unknown): boolean { - if (err instanceof TypeError) { - const msg = err.message.toLowerCase(); - return ( - msg.includes("failed to fetch") || - msg.includes("networkerror") || - msg.includes("load failed") || - 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; -} - -function newLocalId(): string { - return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -} - -/** 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)}`; -} +// isNetworkError / newLocalId / newIdempotencyKey are shared from offline-write. /** Body for a create-order POST. */ function buildCreateBody( @@ -194,13 +169,13 @@ export async function submitOrderToApi({ return await apiPost( `/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, { items: pending }, - { idempotencyKey } + { idempotencyKey, offline: "manual" } ); } return await apiPost( `/api/cafes/${cafeId}/orders`, buildCreateBody(params, pending), - { idempotencyKey } + { idempotencyKey, offline: "manual" } ); } catch (err) { // Only fall back to the offline queue on a genuine network failure; a real diff --git a/web/dashboard/src/lib/use-api-error.ts b/web/dashboard/src/lib/use-api-error.ts index 7d74742..e3cdde9 100644 --- a/web/dashboard/src/lib/use-api-error.ts +++ b/web/dashboard/src/lib/use-api-error.ts @@ -13,8 +13,14 @@ import { ApiClientError } from "@/lib/api/client"; export function useApiError() { const t = useTranslations("errors"); return (err: unknown, fallback?: string): string => { - if (err instanceof ApiClientError && err.code && t.has(err.code)) { - return t(err.code); + const code = + err instanceof ApiClientError + ? err.code + : typeof err === "object" && err !== null && "code" in err + ? String((err as { code: unknown }).code) + : undefined; + if (code && t.has(code)) { + return t(code); } return fallback ?? t("generic"); };