Files
route-commerce/src/actions/offline-dispatcher.ts
T

92 lines
3.4 KiB
TypeScript

"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<ClientAction[]> {
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 };
}
}