Files
ERP/src/lib/offlineQueue.ts
T
denis_ireri 8fe50af341 Show Offline when the server is unreachable, and document the pilot changes
The terminal's badge only looked at navigator.onLine, so it said Online
while the server was down. It now also reflects whether the last sync or
health check reached the server. The cart badge no longer covers the
product name.

docs/PILOT_READINESS.md records what changed against the PRD review, the
decisions taken (offline sales may take stock negative, transfers may
not, server price wins but differences are flagged), rollout notes, and
what is still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:54:24 +03:00

268 lines
8.2 KiB
TypeScript

import { openDB, DBSchema, IDBPDatabase } from "idb";
import { apiFetch, ApiError } from "@/lib/api";
import { MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol";
/**
* The terminal's durable sales queue (IndexedDB).
*
* Every cash sale is written here BEFORE the cashier sees "recorded", and
* stays here until the server has confirmed it. Each row carries the
* cashier and branch that recorded it, and a device only ever sends the
* signed-in cashier's rows — so on a shared phone, sales can never be
* credited to whoever signs in next.
*
* Row lifecycle:
* pending -> sent on the next sync (immediately when online)
* synced -> server answered saved/duplicate; kept 7 days for the receipt history
* rejected -> server can never accept it (reason stored); shown to the cashier,
* never retried, so it can't block the queue
*/
export interface SaleLine {
productId: string;
label: string;
quantity: number;
unitPrice: number; // the price the cashier saw and charged
}
export type SaleState = "pending" | "synced" | "rejected";
export interface QueuedSale {
clientUuid: string;
userId: string;
businessId: string;
branchId: string;
customerName?: string;
customerPhone?: string;
lines: SaleLine[];
total: number;
clientSubmittedAt: string;
state: SaleState;
error?: string;
syncedAt?: string;
}
/** Rows written by the pre-v2 terminal (no owner, one product). */
interface LegacyCashSale {
clientUuid: string;
customerName?: string;
customerPhone: string;
productId: string;
quantity: number;
clientSubmittedAt: string;
synced: boolean;
}
interface QueueDB extends DBSchema {
sales: { key: string; value: QueuedSale; indexes: { byUser: string } };
cash_sales: { key: string; value: LegacyCashSale };
}
export interface QueueOwner {
id: string;
businessId?: string;
branchId: string | null;
}
const DB_NAME = "aquaflow-offline";
const KEEP_SYNCED_MS = 7 * 24 * 60 * 60 * 1000;
let dbPromise: Promise<IDBPDatabase<QueueDB>> | null = null;
function getDb() {
if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser");
if (!dbPromise) {
dbPromise = openDB<QueueDB>(DB_NAME, 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) db.createObjectStore("cash_sales", { keyPath: "clientUuid" });
if (oldVersion < 2) {
const sales = db.createObjectStore("sales", { keyPath: "clientUuid" });
sales.createIndex("byUser", "userId");
}
},
});
}
return dbPromise;
}
export function generateClientUuid(): string {
return crypto.randomUUID();
}
export async function queueSale(sale: Omit<QueuedSale, "state">): Promise<QueuedSale> {
const row: QueuedSale = { ...sale, state: "pending" };
const db = await getDb();
await db.put("sales", row);
return row;
}
export async function salesFor(userId: string): Promise<QueuedSale[]> {
const db = await getDb();
return db.getAllFromIndex("sales", "byUser", userId);
}
export async function queueCounts(userId: string): Promise<{ pending: number; rejected: number }> {
const rows = await salesFor(userId);
return {
pending: rows.filter((r) => r.state === "pending").length,
rejected: rows.filter((r) => r.state === "rejected").length,
};
}
/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */
export async function dismissRejected(clientUuid: string): Promise<void> {
const db = await getDb();
const row = await db.get("sales", clientUuid);
if (row?.state === "rejected") await db.delete("sales", clientUuid);
}
async function applyResults(results: SaleResult[]) {
const db = await getDb();
const tx = db.transaction("sales", "readwrite");
for (const r of results) {
const row = await tx.store.get(r.clientUuid);
if (!row) continue;
if (r.status === "rejected") {
row.state = "rejected";
row.error = r.error;
} else {
row.state = "synced";
row.syncedAt = new Date().toISOString();
delete row.error;
}
await tx.store.put(row);
}
await tx.done;
}
function toPayload(s: QueuedSale) {
return {
clientUuid: s.clientUuid,
staffId: s.userId,
branchId: s.branchId,
customerName: s.customerName || undefined,
customerPhone: s.customerPhone || undefined,
clientSubmittedAt: s.clientSubmittedAt,
lines: s.lines.map((l) => ({
productId: l.productId,
quantity: l.quantity,
// Adopted legacy rows never recorded a price (0); don't flag those as mismatches.
unitPrice: l.unitPrice > 0 ? l.unitPrice : undefined,
})),
};
}
/**
* Pre-v2 rows had no owner. They are adopted by the first cashier who
* syncs on this device — exactly what the old terminal did with them.
*/
async function adoptLegacySales(owner: QueueOwner) {
if (!owner.branchId) return;
const db = await getDb();
const legacy = (await db.getAll("cash_sales")).filter((s) => !s.synced);
for (const s of legacy) {
await db.put("sales", {
clientUuid: s.clientUuid,
userId: owner.id,
businessId: owner.businessId ?? "",
branchId: owner.branchId,
customerName: s.customerName,
customerPhone: s.customerPhone,
lines: [{ productId: s.productId, label: "Sale", quantity: s.quantity, unitPrice: 0 }],
total: 0,
clientSubmittedAt: s.clientSubmittedAt,
state: "pending",
});
}
await db.clear("cash_sales");
}
async function pruneSynced(userId: string) {
const db = await getDb();
const cutoff = Date.now() - KEEP_SYNCED_MS;
for (const row of await salesFor(userId)) {
if (row.state === "synced" && row.syncedAt && Date.parse(row.syncedAt) < cutoff) {
await db.delete("sales", row.clientUuid);
}
}
}
export interface FlushResult {
synced: number;
rejected: number;
pending: number;
offline: boolean;
}
let flushInFlight: Promise<FlushResult> | null = null;
/**
* Send this cashier's pending sales in chunks the server accepts. Stops at
* the first network/auth failure (everything unsent stays pending).
*/
export function flushQueue(owner: QueueOwner): Promise<FlushResult> {
if (!flushInFlight) {
flushInFlight = doFlush(owner).finally(() => {
flushInFlight = null;
});
}
return flushInFlight;
}
async function doFlush(owner: QueueOwner): Promise<FlushResult> {
await adoptLegacySales(owner);
const pending = (await salesFor(owner.id))
.filter((s) => s.state === "pending")
.sort((a, b) => a.clientSubmittedAt.localeCompare(b.clientSubmittedAt));
let synced = 0;
let rejected = 0;
let offline = false;
if (pending.length === 0) {
// Nothing to send: still find out whether the server is reachable, so
// the terminal's Online/Offline badge is truthful.
offline = !(await fetch("/api/health", { cache: "no-store" }).then((r) => r.ok).catch(() => false));
}
for (let i = 0; i < pending.length; i += MAX_SALES_PER_REQUEST) {
const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST);
try {
const { results } = await apiFetch<{ results: SaleResult[] }>("/api/transactions/cash", {
method: "POST",
body: JSON.stringify({ sales: chunk.map(toPayload) }),
});
await applyResults(results);
synced += results.filter((r) => r.status !== "rejected").length;
rejected += results.filter((r) => r.status === "rejected").length;
} catch (err) {
// 401/403: signed out or blocked — keep everything for later.
// Network error: offline — same. Nothing is ever dropped here.
offline = !(err instanceof ApiError);
break;
}
}
await pruneSynced(owner.id);
const counts = await queueCounts(owner.id);
return { synced, rejected, pending: counts.pending, offline };
}
/** Flush on reconnect and every 30s while online. Returns an unsubscribe. */
export function registerOfflineSync(owner: QueueOwner, onFlush?: (r: FlushResult) => void) {
if (typeof window === "undefined") return () => {};
const handler = async () => {
const result = await flushQueue(owner);
onFlush?.(result);
};
window.addEventListener("online", handler);
if (navigator.onLine) handler();
const interval = setInterval(() => {
if (navigator.onLine) handler();
}, 30000);
return () => {
window.removeEventListener("online", handler);
clearInterval(interval);
};
}