diff --git a/src/app/services/[serviceId]/edit/page.tsx b/src/app/services/[serviceId]/edit/page.tsx index 5765018..fd5d31f 100644 --- a/src/app/services/[serviceId]/edit/page.tsx +++ b/src/app/services/[serviceId]/edit/page.tsx @@ -3,12 +3,13 @@ import { useEffect, useState, use } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { apiGet, apiPatch } from "@/lib/apiClient"; +import { apiGet } from "@/lib/apiClient"; import { PageShell } from "@/components/PageShell"; import { TextField } from "@/components/TextField"; import { Spinner } from "@/components/Spinner"; import { useToast } from "@/components/ToastProvider"; import { parseNonNegativeInt } from "@/lib/validateNumber"; +import { runServiceMutation } from "@/lib/serviceMutationQueue"; type Service = { serviceId: string; priceStroops: number }; @@ -27,11 +28,6 @@ export default function EditServicePage({ const [prefillError, setPrefillError] = useState(null); const [originalPrice, setOriginalPrice] = useState(null); - /** - * Whether the price field differs from the originally-fetched value. - * Derived during render (not stored in state) so it stays in sync without a - * setState-in-effect. Used by the unsaved-changes guard. - */ const dirty = originalPrice !== null && price !== originalPrice; useEffect(() => { @@ -54,11 +50,6 @@ export default function EditServicePage({ void load(); }, [serviceId]); - /* - * beforeunload guard: registers a `beforeunload` event on the window - * whenever the form is dirty so the browser prompts the operator before - * closing or navigating away. - */ useEffect(() => { if (!dirty) return; @@ -86,11 +77,18 @@ export default function EditServicePage({ setSaving(true); try { - await apiPatch( - `/api/v1/services/${encodeURIComponent(serviceId)}/price`, - { priceStroops: parsed.value } - ); + const result = await runServiceMutation({ + kind: "service.price.update", + serviceId, + path: `/api/v1/services/${encodeURIComponent(serviceId)}/price`, + body: { priceStroops: parsed.value }, + basePriceStroops: Number(originalPrice), + }); setOriginalPrice(price); + if (result.queued) { + toast.push("Price change queued. It will sync when you reconnect.", "info"); + return; + } toast.push("Price updated.", "info"); router.push(`/services/${encodeURIComponent(serviceId)}`); } catch (err) { diff --git a/src/app/services/new/offline-queue.integration.test.tsx b/src/app/services/new/offline-queue.integration.test.tsx new file mode 100644 index 0000000..01c388c --- /dev/null +++ b/src/app/services/new/offline-queue.integration.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import NewServicePage from "./page"; +import { apiGet, apiPatch, apiPost } from "@/lib/apiClient"; +import { SERVICE_MUTATION_QUEUE_STORAGE_KEY } from "@/lib/serviceMutationQueue"; + +jest.mock("@/lib/apiClient", () => ({ + apiGet: jest.fn(), + apiPatch: jest.fn(), + apiPost: jest.fn(), +})); + +const mockPush = jest.fn(); + +jest.mock("next/navigation", () => ({ + useRouter() { + return { + push: mockPush, + replace: jest.fn(), + prefetch: jest.fn(), + }; + }, +})); + +const apiGetMock = apiGet as jest.MockedFunction; +const apiPatchMock = apiPatch as jest.MockedFunction; +const apiPostMock = apiPost as jest.MockedFunction; + +function setOnline(value: boolean) { + Object.defineProperty(window.navigator, "onLine", { + value, + configurable: true, + }); +} + +describe("NewServicePage offline mutation integration", () => { + beforeEach(() => { + localStorage.clear(); + mockPush.mockReset(); + apiGetMock.mockReset(); + apiPatchMock.mockReset(); + apiPostMock.mockReset(); + setOnline(false); + }); + + afterEach(() => { + setOnline(true); + localStorage.clear(); + }); + + it("persists a submitted service while offline and surfaces queued UI state", async () => { + render(); + + fireEvent.change(screen.getByLabelText("Service ID"), { + target: { value: "offline-svc" }, + }); + fireEvent.change(screen.getByLabelText("Price (stroops / request)"), { + target: { value: "42" }, + }); + fireEvent.submit(screen.getByRole("button", { name: /register service/i })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /queued for sync/i }), + ).toBeDisabled(); + }); + + expect(screen.getByRole("status")).toHaveTextContent( + /saved on this device and will sync when connectivity returns/i, + ); + expect(apiPostMock).not.toHaveBeenCalled(); + expect(mockPush).not.toHaveBeenCalled(); + + const stored = JSON.parse( + localStorage.getItem(SERVICE_MUTATION_QUEUE_STORAGE_KEY)!, + ); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ + kind: "service.create", + serviceId: "offline-svc", + path: "/api/v1/services", + body: { serviceId: "offline-svc", priceStroops: 42 }, + state: "pending", + }); + }); +}); diff --git a/src/app/services/new/page.tsx b/src/app/services/new/page.tsx index 07547e2..e1b5a80 100644 --- a/src/app/services/new/page.tsx +++ b/src/app/services/new/page.tsx @@ -2,12 +2,12 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; -import { apiPost } from "@/lib/apiClient"; import { PageShell } from "@/components/PageShell"; import { TextField } from "@/components/TextField"; import { Button } from "@/components/Button"; import { parseNonNegativeInt } from "@/lib/validateNumber"; import { useApiMutation } from "@/lib/useApiMutation"; +import { runServiceMutation } from "@/lib/serviceMutationQueue"; type CreateServiceBody = { serviceId: string; @@ -19,10 +19,19 @@ export default function NewServicePage() { const [serviceId, setServiceId] = useState(""); const [priceStroops, setPriceStroops] = useState(""); const [priceError, setPriceError] = useState(null); + const [queued, setQueued] = useState(false); const { mutate, status, error, reset } = useApiMutation( (body: CreateServiceBody, { signal }) => - apiPost("/api/v1/services", body, { signal }), + runServiceMutation( + { + kind: "service.create", + serviceId: body.serviceId, + path: "/api/v1/services", + body, + }, + { signal }, + ), ); const loading = status === "pending"; @@ -30,6 +39,7 @@ export default function NewServicePage() { const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); reset(); + setQueued(false); setPriceError(null); const parsed = parseNonNegativeInt(priceStroops); @@ -39,10 +49,14 @@ export default function NewServicePage() { } try { - await mutate({ + const result = await mutate({ serviceId, priceStroops: parsed.value, }); + if (result.queued) { + setQueued(true); + return; + } router.push("/services"); } catch { // Error message is already mirrored on the mutation `error` state. @@ -74,12 +88,17 @@ export default function NewServicePage() { + {queued && ( +

+ Service registration is saved on this device and will sync when connectivity returns. +

+ )} {error && (

{error} diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx index 79a28a6..f68be74 100644 --- a/src/components/OfflineBanner.tsx +++ b/src/components/OfflineBanner.tsx @@ -1,30 +1,63 @@ "use client"; -import { useCallback, useState } from "react"; +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; import { useOnlineStatus } from "@/lib/useOnlineStatus"; +import { useServiceMutationQueue } from "@/lib/useServiceMutationQueue"; +import { + discardServiceMutationConflicts, + flushServiceMutationQueue, + retryServiceMutationConflicts, +} from "@/lib/serviceMutationQueue"; /** - * A dismissible banner that appears when the browser goes offline. + * Global connectivity + queued-mutation status surface. * - * Accessibility: - * - The banner uses `role="alert"` so assistive technology announces it - * immediately when the connectivity state changes. - * - The dismiss button carries an `aria-label` for screen-reader users. - * - * Satisfies WCAG 4.1.3 Status Messages. + * Queued service mutations live in localStorage, so this banner can flush them + * after a reload or navigation. Conflicts remain visible until the operator + * explicitly retries or discards them. */ export function OfflineBanner() { const { isOnline } = useOnlineStatus(); + const queue = useServiceMutationQueue(); const [dismissed, setDismissed] = useState(false); const dismiss = useCallback(() => setDismissed(true), []); - if (isOnline || dismissed) return null; + useEffect(() => { + setDismissed(false); + if (isOnline) { + void flushServiceMutationQueue(); + } + }, [isOnline]); + + const retry = useCallback(() => { + setDismissed(false); + if (queue.conflicts > 0) { + void retryServiceMutationConflicts(); + } else { + void flushServiceMutationQueue(); + } + }, [queue.conflicts]); + + if (dismissed) return null; + if (isOnline && queue.total === 0 && !queue.isFlushing) return null; + + let message = "You are offline. Some features may be unavailable."; + if (queue.conflicts > 0) { + message = `${queue.conflicts} queued service change${queue.conflicts === 1 ? " has" : "s have"} a server conflict. Review the affected service, then retry or discard the change.`; + } else if (!isOnline && queue.total > 0) { + message = `You are offline. ${queue.total} service change${queue.total === 1 ? " is" : "s are"} queued and will sync when you reconnect.`; + } else if (queue.isFlushing) { + message = `Syncing ${queue.pending} queued service change${queue.pending === 1 ? "" : "s"}…`; + } else if (isOnline && queue.pending > 0) { + message = `${queue.pending} service change${queue.pending === 1 ? " is" : "s are"} waiting to sync. Retry when the connection is available.`; + } return (

- You are offline. Some features may be unavailable. + {message} + {queue.conflictItems.slice(0, 3).map(({ serviceId, errorCode }) => ( + + {serviceId} ({errorCode}) + + ))} + {isOnline && queue.total > 0 && !queue.isFlushing && ( + + )} + {queue.conflicts > 0 && ( + + )} diff --git a/src/lib/__tests__/serviceMutationQueue.test.ts b/src/lib/__tests__/serviceMutationQueue.test.ts new file mode 100644 index 0000000..6a8cbd9 --- /dev/null +++ b/src/lib/__tests__/serviceMutationQueue.test.ts @@ -0,0 +1,340 @@ +import { apiGet, apiPatch, apiPost } from "@/lib/apiClient"; +import { + SERVICE_MUTATION_QUEUE_STORAGE_KEY, + ServiceMutationError, + flushServiceMutationQueue, + getServiceMutationQueueSnapshot, + runServiceMutation, +} from "@/lib/serviceMutationQueue"; + +jest.mock("@/lib/apiClient", () => ({ + apiGet: jest.fn(), + apiPatch: jest.fn(), + apiPost: jest.fn(), +})); + +const apiGetMock = apiGet as jest.MockedFunction; +const apiPatchMock = apiPatch as jest.MockedFunction; +const apiPostMock = apiPost as jest.MockedFunction; + +function setOnline(value: boolean) { + Object.defineProperty(navigator, "onLine", { value, configurable: true }); +} + +describe("serviceMutationQueue", () => { + beforeEach(() => { + localStorage.clear(); + apiGetMock.mockReset(); + apiPatchMock.mockReset(); + apiPostMock.mockReset(); + global.fetch = jest.fn(); + setOnline(true); + }); + + it("persists an offline mutation across a reload-shaped storage read", async () => { + setOnline(false); + const result = await runServiceMutation({ + kind: "service.create", + serviceId: "offline-svc", + path: "/api/v1/services", + body: { serviceId: "offline-svc", priceStroops: 10 }, + }); + + expect(result.queued).toBe(true); + expect(JSON.parse(localStorage.getItem(SERVICE_MUTATION_QUEUE_STORAGE_KEY)!)).toHaveLength(1); + expect(getServiceMutationQueueSnapshot()).toMatchObject({ pending: 1, conflicts: 0, total: 1 }); + }); + + it("returns a stable typed safe error for immediate server failures", async () => { + apiPostMock.mockRejectedValueOnce( + Object.assign(new Error("database internals should never reach the UI"), { + status: 500, + }), + ); + + let thrown: unknown; + try { + await runServiceMutation({ + kind: "service.create", + serviceId: "safe-error", + path: "/api/v1/services", + body: { serviceId: "safe-error", priceStroops: 10 }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(ServiceMutationError); + expect(thrown).toMatchObject({ + code: "server_unavailable", + serviceId: "safe-error", + status: 500, + message: "The service could not be saved right now. Try again.", + }); + expect((thrown as Error).message).not.toContain("database internals"); + expect(getServiceMutationQueueSnapshot().total).toBe(0); + }); + + it("flushes queued mutations in creation order", async () => { + setOnline(false); + await runServiceMutation({ + kind: "service.price.update", + serviceId: "svc-a", + path: "/api/v1/services/svc-a/price", + body: { priceStroops: 20 }, + basePriceStroops: 10, + }); + await runServiceMutation({ + kind: "service.price.update", + serviceId: "svc-b", + path: "/api/v1/services/svc-b/price", + body: { priceStroops: 30 }, + basePriceStroops: 15, + }); + + setOnline(true); + (global.fetch as jest.Mock).mockResolvedValue({ ok: true }); + apiGetMock + .mockResolvedValueOnce({ serviceId: "svc-a", priceStroops: 10 } as never) + .mockResolvedValueOnce({ serviceId: "svc-a", priceStroops: 20 } as never) + .mockResolvedValueOnce({ serviceId: "svc-b", priceStroops: 15 } as never) + .mockResolvedValueOnce({ serviceId: "svc-b", priceStroops: 30 } as never); + + await flushServiceMutationQueue(); + + expect((global.fetch as jest.Mock).mock.calls.map(([url]) => url)).toEqual([ + "http://localhost:3001/api/v1/services/svc-a/price", + "http://localhost:3001/api/v1/services/svc-b/price", + ]); + expect(getServiceMutationQueueSnapshot().total).toBe(0); + }); + + it("coalesces duplicate queue requests and single-flights duplicate flushes", async () => { + setOnline(false); + const mutation = { + kind: "service.create" as const, + serviceId: "once", + path: "/api/v1/services", + body: { serviceId: "once", priceStroops: 40 }, + }; + await runServiceMutation(mutation); + await runServiceMutation(mutation); + + setOnline(true); + (global.fetch as jest.Mock).mockResolvedValue({ ok: true }); + apiGetMock + .mockRejectedValueOnce(new Error("not found")) + .mockResolvedValue({ serviceId: "once", priceStroops: 40 } as never); + + await Promise.all([flushServiceMutationQueue(), flushServiceMutationQueue()]); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(getServiceMutationQueueSnapshot().total).toBe(0); + }); + + it("does not replay a queued create when the existence preflight is unavailable", async () => { + setOnline(false); + await runServiceMutation({ + kind: "service.create", + serviceId: "uncertain", + path: "/api/v1/services", + body: { serviceId: "uncertain", priceStroops: 40 }, + }); + + setOnline(true); + apiGetMock.mockRejectedValueOnce(new TypeError("temporary read failure")); + + await flushServiceMutationQueue(); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(getServiceMutationQueueSnapshot()).toMatchObject({ + pending: 1, + conflicts: 0, + total: 1, + }); + }); + + it("surfaces a server conflict instead of replaying past it", async () => { + setOnline(false); + await runServiceMutation({ + kind: "service.create", + serviceId: "conflicted", + path: "/api/v1/services", + body: { serviceId: "conflicted", priceStroops: 50 }, + }); + + setOnline(true); + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 409, + statusText: "Conflict", + json: async () => ({ message: "already exists" }), + }); + apiGetMock.mockRejectedValueOnce(Object.assign(new Error("not found"), { status: 404 })); + apiGetMock.mockResolvedValue({ serviceId: "conflicted", priceStroops: 99 } as never); + + await flushServiceMutationQueue(); + + expect(getServiceMutationQueueSnapshot()).toMatchObject({ pending: 0, conflicts: 1, total: 1 }); + const stored = JSON.parse(localStorage.getItem(SERVICE_MUTATION_QUEUE_STORAGE_KEY)!); + expect(stored[0]).toMatchObject({ state: "conflict", errorCode: "server_conflict" }); + }); + + it("keeps an edit as a conflict when the server price changed while offline", async () => { + setOnline(false); + await runServiceMutation({ + kind: "service.price.update", + serviceId: "changed", + path: "/api/v1/services/changed/price", + body: { priceStroops: 20 }, + basePriceStroops: 10, + }); + setOnline(true); + apiGetMock.mockResolvedValue({ serviceId: "changed", priceStroops: 15 } as never); + + await flushServiceMutationQueue(); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(getServiceMutationQueueSnapshot()).toMatchObject({ conflicts: 1, total: 1 }); + }); + + it("surfaces a deleted edit target as a conflict instead of retrying forever", async () => { + setOnline(false); + await runServiceMutation({ + kind: "service.price.update", + serviceId: "deleted", + path: "/api/v1/services/deleted/price", + body: { priceStroops: 20 }, + basePriceStroops: 10, + }); + + setOnline(true); + apiGetMock.mockRejectedValueOnce( + Object.assign(new Error("not found"), { status: 404 }), + ); + + await flushServiceMutationQueue(); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(getServiceMutationQueueSnapshot()).toMatchObject({ + pending: 0, + conflicts: 1, + total: 1, + }); + const stored = JSON.parse( + localStorage.getItem(SERVICE_MUTATION_QUEUE_STORAGE_KEY)!, + ); + expect(stored[0]).toMatchObject({ + state: "conflict", + errorCode: "server_conflict", + }); + }); + + it("retains a confirmed write until readback can verify its server state", async () => { + setOnline(false); + await runServiceMutation({ + kind: "service.price.update", + serviceId: "retry", + path: "/api/v1/services/retry/price", + body: { priceStroops: 20 }, + basePriceStroops: 10, + }); + setOnline(true); + apiGetMock + .mockResolvedValueOnce({ serviceId: "retry", priceStroops: 10 } as never) + .mockRejectedValueOnce(new Error("read unavailable")) + .mockResolvedValueOnce({ serviceId: "retry", priceStroops: 20 } as never); + (global.fetch as jest.Mock).mockResolvedValue({ ok: true }); + + await flushServiceMutationQueue(); + expect(getServiceMutationQueueSnapshot().pending).toBe(1); + await flushServiceMutationQueue(); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(getServiceMutationQueueSnapshot().total).toBe(0); + }); + + it("does not collapse A -> B -> A into a duplicate pending edit", async () => { + setOnline(false); + for (const [basePriceStroops, price] of [[10, 20], [20, 30], [30, 20]]) { + await runServiceMutation({ + kind: "service.price.update", + serviceId: "sequence", + path: "/api/v1/services/sequence/price", + body: { priceStroops: price }, + basePriceStroops, + }); + } + expect(getServiceMutationQueueSnapshot().total).toBe(3); + }); + it("preserves persisted FIFO order even when createdAt moves backwards", async () => { + setOnline(false); + await runServiceMutation({ + kind: "service.price.update", + serviceId: "first", + path: "/api/v1/services/first/price", + body: { priceStroops: 20 }, + basePriceStroops: 10, + }); + await runServiceMutation({ + kind: "service.price.update", + serviceId: "second", + path: "/api/v1/services/second/price", + body: { priceStroops: 30 }, + basePriceStroops: 15, + }); + + const stored = JSON.parse(localStorage.getItem(SERVICE_MUTATION_QUEUE_STORAGE_KEY)!); + stored[0].createdAt = 2_000; + stored[1].createdAt = 1_000; + localStorage.setItem(SERVICE_MUTATION_QUEUE_STORAGE_KEY, JSON.stringify(stored)); + + setOnline(true); + (global.fetch as jest.Mock).mockResolvedValue({ ok: true }); + apiGetMock + .mockResolvedValueOnce({ serviceId: "first", priceStroops: 10 } as never) + .mockResolvedValueOnce({ serviceId: "first", priceStroops: 20 } as never) + .mockResolvedValueOnce({ serviceId: "second", priceStroops: 15 } as never) + .mockResolvedValueOnce({ serviceId: "second", priceStroops: 30 } as never); + + await flushServiceMutationQueue(); + + expect((global.fetch as jest.Mock).mock.calls.map(([url]) => url)).toEqual([ + "http://localhost:3001/api/v1/services/first/price", + "http://localhost:3001/api/v1/services/second/price", + ]); + expect(getServiceMutationQueueSnapshot().total).toBe(0); + }); + + it("keeps HTTP 429 work pending instead of misclassifying it as a conflict", async () => { + setOnline(false); + await runServiceMutation({ + kind: "service.price.update", + serviceId: "rate-limited", + path: "/api/v1/services/rate-limited/price", + body: { priceStroops: 20 }, + basePriceStroops: 10, + }); + + setOnline(true); + apiGetMock.mockResolvedValueOnce({ + serviceId: "rate-limited", + priceStroops: 10, + } as never); + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 429, + statusText: "Too Many Requests", + }); + + await flushServiceMutationQueue(); + + expect(getServiceMutationQueueSnapshot()).toMatchObject({ + pending: 1, + conflicts: 0, + total: 1, + }); + }); + +}); + diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 21945ab..74a55f3 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -105,6 +105,7 @@ function createHttpError(status: number, body: unknown, statusText = "") { const err = new Error(message); return Object.assign(err, apiError ?? {}, { + status, error: typeof apiError?.error === "string" && apiError.error.length > 0 ? apiError.error diff --git a/src/lib/serviceMutationQueue.ts b/src/lib/serviceMutationQueue.ts new file mode 100644 index 0000000..c9108cf --- /dev/null +++ b/src/lib/serviceMutationQueue.ts @@ -0,0 +1,462 @@ +"use client"; + +import { apiGet, apiPatch, apiPost } from "@/lib/apiClient"; +import { resolveApiBase } from "@/lib/resolveApiBase"; + +const STORAGE_KEY = "agentpay:services:mutation-queue:v1"; +const CHANGE_EVENT = "agentpay:services:mutation-queue-change"; + +export type ServiceMutationKind = "service.create" | "service.price.update"; +export type ServiceMutationState = "pending" | "conflict"; + +export type ServiceMutation = { + id: string; + fingerprint: string; + kind: ServiceMutationKind; + serviceId: string; + path: string; + body: Record; + /** Price seen by the editor. A reconnect must not replace a newer price. */ + basePriceStroops?: number; + createdAt: number; + state: ServiceMutationState; + errorCode?: "server_conflict" | "server_rejected"; + errorMessage?: string; +}; + +export type ServiceMutationQueueSnapshot = { + pending: number; + conflicts: number; + total: number; + isFlushing: boolean; + conflictItems: Array<{ serviceId: string; errorCode: "server_conflict" | "server_rejected" }>; +}; + +export type RunServiceMutationResult = + | { queued: false; data: T } + | { queued: true; mutation: ServiceMutation }; + +export type ServiceMutationExecutionErrorCode = + | "server_conflict" + | "server_rejected" + | "server_unavailable" + | "rate_limited"; + +export class ServiceMutationError extends Error { + readonly code: ServiceMutationExecutionErrorCode; + readonly serviceId: string; + readonly status: number | null; + + constructor( + code: ServiceMutationExecutionErrorCode, + serviceId: string, + message: string, + status: number | null, + ) { + super(message); + this.name = "ServiceMutationError"; + this.code = code; + this.serviceId = serviceId; + this.status = status; + } +} + +let flushPromise: Promise | null = null; +const FLUSH_LOCK = "agentpay:services:mutation-queue-flush"; + +type MutationInput = Pick; + +function canUseStorage(): boolean { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +function readQueue(): ServiceMutation[] { + if (!canUseStorage()) return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter((entry): entry is ServiceMutation => { + if (!entry || typeof entry !== "object") return false; + const item = entry as Partial; + return ( + typeof item.id === "string" && + typeof item.fingerprint === "string" && + (item.kind === "service.create" || item.kind === "service.price.update") && + typeof item.serviceId === "string" && + typeof item.path === "string" && + typeof item.createdAt === "number" && + (item.state === "pending" || item.state === "conflict") && + !!item.body && + typeof item.body === "object" && + !Array.isArray(item.body) && + (item.kind !== "service.price.update" || + typeof item.basePriceStroops === "number") + ); + }); + } catch { + return []; + } +} + +function emitChange(): void { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(CHANGE_EVENT)); + } +} + +function writeQueue(queue: ServiceMutation[]): void { + if (!canUseStorage()) return; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(queue)); + emitChange(); +} + +function stableBody(body: Record): string { + return JSON.stringify( + Object.keys(body) + .sort() + .reduce>((result, key) => { + result[key] = body[key]; + return result; + }, {}), + ); +} + +function fingerprintOf( + kind: ServiceMutationKind, + serviceId: string, + path: string, + body: Record, + basePriceStroops?: number, +): string { + return `${kind}:${serviceId}:${path}:${basePriceStroops ?? ""}:${stableBody(body)}`; +} + +function newMutationId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function withQueueLock(work: () => T | Promise): Promise { + const locks = typeof navigator !== "undefined" ? navigator.locks : undefined; + return locks ? locks.request(FLUSH_LOCK, work) : Promise.resolve().then(work); +} + +function enqueueMutation(input: MutationInput): Promise { + return withQueueLock(() => { + const queue = readQueue(); + const fingerprint = fingerprintOf( + input.kind, input.serviceId, input.path, input.body, input.basePriceStroops, + ); + // Only adjacent repeats are duplicate clicks. A -> B -> A must retain all + // three intended changes in order. + const last = queue.at(-1); + if (last?.state === "pending" && last.fingerprint === fingerprint) return last; + + const mutation: ServiceMutation = { + ...input, + id: newMutationId(), + fingerprint, + createdAt: Date.now(), + state: "pending", + }; + writeQueue([...queue, mutation]); + return mutation; + }); +} + +function errorStatus(error: unknown): number | null { + if (!error || typeof error !== "object") return null; + const status = (error as { status?: unknown }).status; + return typeof status === "number" ? status : null; +} + +function isNetworkFailure(error: unknown): boolean { + if (typeof navigator !== "undefined" && !navigator.onLine) return true; + return error instanceof TypeError || (error instanceof Error && error.name === "ApiTimeoutError"); +} + +function safeExecutionError(serviceId: string, error: unknown): ServiceMutationError { + if (error instanceof ServiceMutationError) return error; + + const status = errorStatus(error); + if (status === 409 || status === 412) { + return new ServiceMutationError( + "server_conflict", + serviceId, + "Server state changed. Refresh the service before retrying.", + status, + ); + } + if (status === 429 || (error instanceof Error && error.name === "ApiRateLimitedError")) { + return new ServiceMutationError( + "rate_limited", + serviceId, + "Too many requests. Try again shortly.", + status, + ); + } + if (status === null || status >= 500) { + return new ServiceMutationError( + "server_unavailable", + serviceId, + "The service could not be saved right now. Try again.", + status, + ); + } + return new ServiceMutationError( + "server_rejected", + serviceId, + "The server rejected the service change.", + status, + ); +} + +async function sendMutation( + mutation: Pick, + signal?: AbortSignal, +): Promise { + if (mutation.kind === "service.create") { + return signal + ? apiPost(mutation.path, mutation.body, { signal }) + : apiPost(mutation.path, mutation.body); + } + return signal + ? apiPatch(mutation.path, mutation.body, { signal }) + : apiPatch(mutation.path, mutation.body); +} + + +async function sendQueuedMutation(mutation: ServiceMutation): Promise { + const response = await fetch(`${resolveApiBase()}${mutation.path}`, { + method: mutation.kind === "service.create" ? "POST" : "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(mutation.body), + signal: AbortSignal.timeout(10_000), + }); + + if (response.ok) return; + + throw Object.assign(new Error("Server rejected queued change"), { status: response.status }); +} + +type ServerPriceState = + | { kind: "present"; priceStroops: number } + | { kind: "missing" } + | { kind: "unavailable" }; + +async function serverPriceState(mutation: ServiceMutation): Promise { + try { + const service = await apiGet<{ serviceId: string; priceStroops: number }>( + `/api/v1/services/${encodeURIComponent(mutation.serviceId)}`, + ); + if ( + service.serviceId !== mutation.serviceId || + typeof service.priceStroops !== "number" + ) { + return { kind: "unavailable" }; + } + return { kind: "present", priceStroops: service.priceStroops }; + } catch (error) { + return errorStatus(error) === 404 + ? { kind: "missing" } + : { kind: "unavailable" }; + } +} + +function replaceMutation(next: ServiceMutation): void { + const queue = readQueue(); + writeQueue(queue.map((item) => (item.id === next.id ? next : item))); +} + +function removeMutation(id: string): void { + writeQueue(readQueue().filter((item) => item.id !== id)); +} + +function markConflict(mutation: ServiceMutation, error: unknown): void { + const conflict = errorStatus(error) === 409 || errorStatus(error) === 412; + replaceMutation({ + ...mutation, + state: "conflict", + errorCode: conflict ? "server_conflict" : "server_rejected", + errorMessage: conflict + ? "Server state changed. Review the service before retrying." + : "Server rejected the queued change.", + }); +} + +/** + * Execute a services mutation immediately while online, otherwise persist it. + * A browser-level network/timeout failure is also converted into a durable + * queued mutation so an in-flight disconnect does not lose the operator's edit. + */ +export async function runServiceMutation( + input: MutationInput, + options: { signal?: AbortSignal } = {}, +): Promise> { + if (input.kind === "service.price.update" && + typeof input.basePriceStroops !== "number") { + throw new Error("Original service price is required for offline updates"); + } + if (options.signal?.aborted) throw options.signal.reason; + if ((typeof navigator !== "undefined" && !navigator.onLine) || + readQueue().length > 0) { + return { queued: true, mutation: await enqueueMutation(input) }; + } + + try { + const data = await sendMutation(input, options.signal); + return { queued: false, data }; + } catch (error) { + if (options.signal?.aborted) throw error; + if (isNetworkFailure(error)) { + return { queued: true, mutation: await enqueueMutation(input) }; + } + throw safeExecutionError(input.serviceId, error); + } +} + +/** + * Flush queued service mutations in persisted enqueue order. A conflict stops the flush to preserve causal + * ordering; the operator can retry or discard that conflict from the banner. + * + * Service IDs prevent duplicate creates. A price update is sent only when the + * server still has the price seen by the editor. The browser Web Lock prevents + * another tab from flushing the same queue concurrently where supported. + */ +export function flushServiceMutationQueue(): Promise { + if (flushPromise) return flushPromise; + + const flush = async () => { + for (;;) { + if (typeof navigator !== "undefined" && !navigator.onLine) break; + const queue = readQueue(); + const mutation = queue[0]; + if (!mutation || mutation.state === "conflict") break; + + try { + const before = await serverPriceState(mutation); + if ( + before.kind === "present" && + before.priceStroops === mutation.body.priceStroops + ) { + removeMutation(mutation.id); + continue; + } + // A failed preflight is not evidence that a create target is absent. + // Hold the queue rather than replaying a mutation against unknown state. + if (before.kind === "unavailable") break; + + if (mutation.kind === "service.price.update") { + if (before.kind === "missing") { + markConflict( + mutation, + Object.assign(new Error("Queued update target no longer exists"), { + status: 409, + }), + ); + break; + } + if (before.priceStroops !== mutation.basePriceStroops) { + markConflict(mutation, Object.assign(new Error(), { status: 409 })); + break; + } + } else if (before.kind === "present") { + markConflict(mutation, Object.assign(new Error(), { status: 409 })); + break; + } + + await sendQueuedMutation(mutation); + const after = await serverPriceState(mutation); + if (after.kind !== "present") break; // Keep the write for safe readback on retry. + if (after.priceStroops !== mutation.body.priceStroops) { + markConflict( + mutation, + Object.assign(new Error("Server state differs from the queued change"), { status: 409 }), + ); + break; + } + removeMutation(mutation.id); + } catch (error) { + const status = errorStatus(error); + if (status === 409 || status === 412) { + const after = await serverPriceState(mutation); + if ( + after.kind === "present" && + after.priceStroops === mutation.body.priceStroops + ) { + removeMutation(mutation.id); + continue; + } + markConflict(mutation, error); + break; + } + if (status !== 429 && status !== null && status < 500) { + markConflict(mutation, error); + } + break; + } + } + }; + const work = withQueueLock(flush); + flushPromise = work.finally(() => { + flushPromise = null; + emitChange(); + }); + emitChange(); + return flushPromise; +} + +export async function retryServiceMutationConflicts(): Promise { + await withQueueLock(() => { + const queue = readQueue().map((item) => + item.state === "conflict" + ? { ...item, state: "pending" as const, errorCode: undefined, errorMessage: undefined } + : item, + ); + writeQueue(queue); + }); + return flushServiceMutationQueue(); +} + +export function discardServiceMutationConflicts(): Promise { + return withQueueLock(() => { + writeQueue(readQueue().filter((item) => item.state !== "conflict")); + }); +} + +export function getServiceMutationQueueSnapshot(): ServiceMutationQueueSnapshot { + const queue = readQueue(); + const conflicts = queue.filter((item) => item.state === "conflict").length; + return { + pending: queue.length - conflicts, + conflicts, + total: queue.length, + isFlushing: flushPromise !== null, + conflictItems: queue + .filter((item) => item.state === "conflict") + .map((item) => ({ + serviceId: item.serviceId, + errorCode: item.errorCode ?? "server_conflict", + })), + }; +} + +export function subscribeServiceMutationQueue(callback: () => void): () => void { + if (typeof window === "undefined") return () => undefined; + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) callback(); + }; + window.addEventListener(CHANGE_EVENT, callback); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(CHANGE_EVENT, callback); + window.removeEventListener("storage", onStorage); + }; +} + +export const SERVICE_MUTATION_QUEUE_STORAGE_KEY = STORAGE_KEY; + diff --git a/src/lib/useServiceMutationQueue.ts b/src/lib/useServiceMutationQueue.ts new file mode 100644 index 0000000..33a26d3 --- /dev/null +++ b/src/lib/useServiceMutationQueue.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + getServiceMutationQueueSnapshot, + subscribeServiceMutationQueue, + type ServiceMutationQueueSnapshot, +} from "@/lib/serviceMutationQueue"; + +const EMPTY: ServiceMutationQueueSnapshot = { + pending: 0, + conflicts: 0, + total: 0, + isFlushing: false, + conflictItems: [], +}; + +export function useServiceMutationQueue(): ServiceMutationQueueSnapshot { + const [snapshot, setSnapshot] = useState(EMPTY); + + useEffect(() => { + const update = () => setSnapshot(getServiceMutationQueueSnapshot()); + update(); + return subscribeServiceMutationQueue(update); + }, []); + + return snapshot; +} +