feat(offline): make every dashboard write durable offline (P2–P5)
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 38s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m11s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 38s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m11s
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.
This commit is contained in:
@@ -245,6 +245,11 @@ public static class ServiceCollectionExtensions
|
||||
"branch-permanent-delete",
|
||||
job => job.ExecuteAsync(),
|
||||
Cron.Hourly);
|
||||
|
||||
RecurringJob.AddOrUpdate<IdempotencyCleanupJob>(
|
||||
"idempotency-cleanup",
|
||||
job => job.ExecuteAsync(),
|
||||
Cron.Daily(4));
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Purges old idempotency records. Keys only need to outlive realistic offline
|
||||
/// gaps and client retries, so a short retention keeps the table small.
|
||||
/// </summary>
|
||||
public class IdempotencyCleanupJob
|
||||
{
|
||||
private static readonly TimeSpan Retention = TimeSpan.FromDays(7);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<IdempotencyCleanupJob> _logger;
|
||||
|
||||
public IdempotencyCleanupJob(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<IdempotencyCleanupJob> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@
|
||||
"notFound": "غير موجود",
|
||||
"unauthorized": "غير مصرح",
|
||||
"network": "خطأ في الاتصال",
|
||||
"generic": "حدث خطأ. حاول مرة أخرى."
|
||||
"generic": "حدث خطأ. حاول مرة أخرى.",
|
||||
"OFFLINE_UNAVAILABLE": "يتطلب هذا الإجراء اتصالاً بالإنترنت. يرجى المحاولة بعد عودة الاتصال."
|
||||
},
|
||||
"brand": {
|
||||
"name": "ميزي"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"notFound": "یافت نشد",
|
||||
"unauthorized": "دسترسی ندارید",
|
||||
"network": "خطای ارتباط با سرور",
|
||||
"generic": "خطایی رخ داد. دوباره تلاش کنید."
|
||||
"generic": "خطایی رخ داد. دوباره تلاش کنید.",
|
||||
"OFFLINE_UNAVAILABLE": "برای این کار به اینترنت نیاز است. لطفاً پس از اتصال دوباره تلاش کنید."
|
||||
},
|
||||
"brand": {
|
||||
"name": "میزی"
|
||||
|
||||
@@ -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<T>(
|
||||
method: "POST" | "PUT" | "PATCH" | "DELETE",
|
||||
url: string,
|
||||
body: unknown,
|
||||
key: string
|
||||
): Promise<T> {
|
||||
const config = { headers: { "Idempotency-Key": key } };
|
||||
let data: ApiResponse<T>;
|
||||
switch (method) {
|
||||
case "POST":
|
||||
({ data } = await api.post<ApiResponse<T>>(url, body, config));
|
||||
break;
|
||||
case "PUT":
|
||||
({ data } = await api.put<ApiResponse<T>>(url, body, config));
|
||||
break;
|
||||
case "PATCH":
|
||||
({ data } = await api.patch<ApiResponse<T>>(url, body, config));
|
||||
break;
|
||||
case "DELETE":
|
||||
({ data } = await api.delete<ApiResponse<T>>(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<T>(
|
||||
method: "POST" | "PUT" | "PATCH" | "DELETE",
|
||||
url: string,
|
||||
body: unknown,
|
||||
opts?: WriteOptions
|
||||
): Promise<T> {
|
||||
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<T>(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<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);
|
||||
}
|
||||
return data.data;
|
||||
return doWrite<T>("POST", url, body, opts);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
return data.data;
|
||||
return doWrite<T>("PUT", url, body, opts);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
return data.data;
|
||||
return doWrite<T>("PATCH", url, body, opts);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
await doWrite<void>("DELETE", url, undefined, opts);
|
||||
}
|
||||
|
||||
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */
|
||||
|
||||
@@ -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<void> {
|
||||
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<unknown> {
|
||||
let createsClientId: string | undefined;
|
||||
let optimistic: unknown;
|
||||
|
||||
if (method === "POST") {
|
||||
createsClientId = newLocalId();
|
||||
optimistic =
|
||||
body && typeof body === "object"
|
||||
? { id: createsClientId, ...(body as Record<string, unknown>) }
|
||||
: { id: createsClientId };
|
||||
} else if (method === "DELETE") {
|
||||
optimistic = undefined;
|
||||
} else {
|
||||
optimistic = body && typeof body === "object" ? { ...(body as Record<string, unknown>) } : body;
|
||||
}
|
||||
|
||||
await enqueueOutboxOp({
|
||||
id: newLocalId(),
|
||||
idempotencyKey,
|
||||
method,
|
||||
url,
|
||||
body,
|
||||
entityType: entityTypeFromUrl(url),
|
||||
createsClientId,
|
||||
idField: "id",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
await refreshBadge();
|
||||
return optimistic;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Order>(
|
||||
`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`,
|
||||
{ items: pending },
|
||||
{ idempotencyKey }
|
||||
{ idempotencyKey, offline: "manual" }
|
||||
);
|
||||
}
|
||||
return await apiPost<Order>(
|
||||
`/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
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user