Add Undo window when receivers cancel an incoming order

Original task (#73): Mirror the existing 7s Undo toast (already in place
for owner-side cancels in My Orders) on the receiver-side cancel action
in the Incoming Orders page.

Implementation:
- artifacts/teaboy-os/src/pages/orders-incoming.tsx
  - Captures the order's previous status (received | preparing) before
    issuing the cancel PATCH.
  - On success, shows a toast with title "Order cancelled" and an
    "Undo" ToastAction. Clicking Undo PATCHes the order status back to
    the previous value, then shows a "Restored" confirmation toast.
  - UNDO_WINDOW_MS = 7000 to match the owner-side flow.
- artifacts/api-server/src/routes/service-orders.ts
  - Refactored the PATCH /orders/:id/status authorization. Detects
    "restore from cancelled" up front (existing.status === cancelled
    and next !== cancelled) and grants permission to:
      - owner/admin: may restore to pending or received (existing rule)
      - original assignee: may restore to received or preparing (NEW)
  - The same 15s server-side undo window applies.
- artifacts/teaboy-os/src/locales/{en,ar}.json
  - Added incomingOrders.cancelled / undo / undone / restoreFailed
    strings in English and Arabic.
- Regenerated lib/api-zod and lib/api-client-react via the api-spec
  codegen script (the previously-cached output was missing pending /
  received / preparing from the request body enum, which now matches
  the OpenAPI spec).

Verification:
- Typecheck passes for api-server and teaboy-os.
- e2e flow verified: cancelling a received order shows the Undo toast;
  clicking Undo restores the order to "received" (DB confirmed);
  letting the window expire leaves the order permanently "cancelled"
  (DB confirmed).

Deviations: None.
Replit-Task-Id: 65c4b053-613a-4898-a0f7-b6fb2d680301
This commit is contained in:
riyadhafraa
2026-04-22 09:58:49 +00:00
parent 36f3704d1d
commit 7d29d63d2d
5 changed files with 117 additions and 32 deletions
@@ -302,7 +302,49 @@ router.patch(
const admin = await isAdmin(userId);
if (next === "preparing" || next === "completed") {
const isRestoreFromCancelled =
existing.status === "cancelled" && next !== "cancelled";
if (isRestoreFromCancelled) {
// Restore a cancelled order back to a prior active status (Undo).
// Owner/admin may restore to pending or received; the original
// assignee may restore to received or preparing.
const isOwner = existing.userId === userId;
const isAssignee = existing.assignedTo === userId;
const canOwnerRestore =
(isOwner || admin) && (next === "pending" || next === "received");
const canAssigneeRestore =
isAssignee && (next === "received" || next === "preparing");
if (!canOwnerRestore && !canAssigneeRestore) {
if (!isOwner && !isAssignee && !admin) {
res.status(403).json({ error: "Forbidden" });
return;
}
res.status(400).json({ error: "invalid_transition" });
return;
}
if (next === "pending" && existing.assignedTo !== null) {
res.status(400).json({ error: "invalid_transition" });
return;
}
if (
(next === "received" || next === "preparing") &&
existing.assignedTo === null
) {
res.status(400).json({ error: "invalid_transition" });
return;
}
// Enforce the undo window: once the window expires, the
// cancellation is permanent. Admin is also bound by this so
// restore is always Undo, never an arbitrary reopen.
const cancelledAt = existing.updatedAt
? new Date(existing.updatedAt).getTime()
: 0;
if (Date.now() - cancelledAt > RESTORE_WINDOW_MS) {
res.status(400).json({ error: "undo_window_expired" });
return;
}
} else if (next === "preparing" || next === "completed") {
// Only the assigned receiver or admin
const isAssignee = existing.assignedTo === userId;
if (!isAssignee && !admin) {
@@ -318,35 +360,10 @@ router.patch(
return;
}
} else if (next === "pending" || next === "received") {
// Restore a cancelled order back to its prior status (Undo).
// Allowed for the owner or admin only.
const isOwner = existing.userId === userId;
if (!isOwner && !admin) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (existing.status !== "cancelled") {
res.status(400).json({ error: "invalid_transition" });
return;
}
// Enforce the undo window: once the window expires, the
// cancellation is permanent. Admin is also bound by this so
// restore is always Undo, never an arbitrary reopen.
const cancelledAt = existing.updatedAt
? new Date(existing.updatedAt).getTime()
: 0;
if (Date.now() - cancelledAt > RESTORE_WINDOW_MS) {
res.status(400).json({ error: "undo_window_expired" });
return;
}
if (next === "pending" && existing.assignedTo !== null) {
res.status(400).json({ error: "invalid_transition" });
return;
}
if (next === "received" && existing.assignedTo === null) {
res.status(400).json({ error: "invalid_transition" });
return;
}
// Restore is handled above; outside cancelled, these targets are
// never valid via this endpoint.
res.status(400).json({ error: "invalid_transition" });
return;
} else if (next === "cancelled") {
const isOwner = existing.userId === userId;
const isAssignee = existing.assignedTo === userId;