"use server"; import { revalidatePath } from "next/cache"; import { getSession } from "@/lib/auth"; /** * Offline mutation dispatcher. * * The v2 mobile admin (PR 3) introduces an IndexedDB-backed mutation * queue (src/lib/offline/queue.ts) and a sync engine * (src/lib/offline/sync.ts) that replays queued actions when the * device comes back online. The client side enqueues an action with a * name + JSON payload + idempotency key (clientActionId), then calls * `syncPending` which invokes this dispatcher for each pending action. * * The dispatcher is the single server-side bridge between the offline * queue and our SECURITY DEFINER server actions. To add a new offline- * capable action, append an entry to `listClientActions()` with: * - a stable name (kebab- or camelCase, the client enqueues by name) * - a handler that accepts the JSON payload and returns * { success: true } | { success: false; error: string } * * The action names referenced by the plan (markOrderReady, * markOrderPickedUp, updateStopStatus, adjustProductStock) do not yet * exist as server actions in the v1 codebase. They're added in * follow-up PRs. For now the dispatcher returns `{ ok: false, error: * "Unknown action" }` for them, which surfaces in the OfflineBanner * conflict count. This keeps the structure in place without coupling * PR 3 to a server-side action surface it doesn't own. * * Idempotency: each enqueue carries a clientActionId (uuid v4). Real * idempotency requires a dedup table on the server — that's also a * follow-up. Until then, replays of the same action will re-execute, * but last-write-wins on the underlying tables is safe for the * mutations we ship (status flips, stock adjust). */ interface ClientAction { name: string; // The handler signature is intentionally loose — payload shapes vary // per action. The dispatcher in `sync.ts` invokes this with the raw // payload from IndexedDB. handler: (payload: unknown, clientActionId: string) => Promise<{ success: boolean; error?: string; }>; } async function listClientActions(): Promise { await getSession(); // Wire real server actions here as they land: // { name: "markOrderReady", handler: markOrderReady }, // { name: "markOrderPickedUp", handler: markOrderPickedUp }, // { name: "updateStopStatus", handler: updateStopStatus }, // { name: "adjustProductStock", handler: adjustProductStock }, return []; } export async function dispatchClientAction( actionName: string, payload: unknown, clientActionId: string, ): Promise< | { ok: true } | { ok: false; conflict: string } | { ok: false; error: string } > { await getSession(); const actions = await listClientActions(); const action = actions.find((a) => a.name === actionName); if (!action) { return { ok: false, error: "Unknown action" }; } try { const result = await action.handler(payload, clientActionId); if (result.success === false) { const message = result.error ?? "Action failed"; if (/conflict|stale/i.test(message)) { return { ok: false, conflict: message }; } return { ok: false, error: message }; } revalidatePath("/admin/v2/orders"); revalidatePath("/admin/v2/orders/[id]", "page"); revalidatePath("/admin/v2/stops"); revalidatePath("/admin/v2/products"); return { ok: true }; } catch (err) { return { ok: false, error: (err as Error).message }; } }