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> | null = null; function getDb() { if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser"); if (!dbPromise) { dbPromise = openDB(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): Promise { const row: QueuedSale = { ...sale, state: "pending" }; const db = await getDb(); await db.put("sales", row); return row; } export async function salesFor(userId: string): Promise { 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 { 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 | 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 { if (!flushInFlight) { flushInFlight = doFlush(owner).finally(() => { flushInFlight = null; }); } return flushInFlight; } async function doFlush(owner: QueueOwner): Promise { 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); }; }