Add Undo window for cancelled orders on My Orders

Original task (#72): Mirror the recently-added delete Undo toast for
the cancel action so accidental cancellations can be reverted within
~7s.

Changes
- Frontend (artifacts/teaboy-os/src/pages/my-orders.tsx):
  OrderCard.handleCancel captures the order's previous status, and on
  successful cancel shows a toast with an "Undo" action (duration
  matches the existing UNDO_WINDOW_MS = 7000ms). Clicking Undo issues
  a PATCH to restore the order to its previous status (pending or
  received) and emits a "Restored" toast. Errors surface a
  "Could not restore the order" toast.
- Cancel confirmation copy was softened (no longer says "can't be
  reopened") and a new restoreFailed string was added in both en.json
  and ar.json.

- Backend (artifacts/api-server/src/routes/service-orders.ts):
  PATCH /orders/:id/status accepts pending and received as targets
  for the owner (or admin) when the existing status is cancelled,
  serving as the restore-from-cancelled transition. We require
  assignedTo to be null for pending and non-null for received so we
  always restore to the actually-prior state. The existing logic
  already broadcasts order_incoming_changed to receivers and emits
  order_updated to the owner, so receivers' incoming queues refresh
  automatically when an order is restored. notifyUser titleMap was
  extended with "Your order was restored" entries.
- Time-bound restore: server enforces a RESTORE_WINDOW_MS of 15s
  (slightly larger than the 7s client window to absorb network/clock
  skew). After it expires, restore is rejected with
  `undo_window_expired`, so cancellation truly becomes permanent —
  this applies to admin too, so restore is strictly Undo and not an
  arbitrary reopen.

- API spec (lib/api-spec/openapi.yaml):
  UpdateServiceOrderStatusBody enum extended to include pending and
  received; endpoint summary updated. Regenerated api-zod and
  api-client-react.

Tests
- Added a new test case in artifacts/api-server/tests/service-orders.test.mjs
  that covers: restore from pending, restore from received,
  pending<->received validation against assignedTo, stranger forbidden,
  and undo window expiry (by backdating updated_at).

Verification
- pnpm typecheck passes across libs and artifacts.
- New api-server test "owner can restore (undo) a freshly cancelled
  order..." passes; all pre-existing service-order tests still pass.
- e2e tested: login -> place order -> cancel -> Undo restores to
  Pending; cancel again -> let window expire -> order stays Cancelled
  with no Undo available.

Notes / unrelated
- The unrelated `admin-app-opens-pagination` test
  (`by-app: paginating with limit returns nextOffset until exhausted`)
  fails in the dev environment because the dev DB has > 100 app_opens
  rows for the chosen app and the test only walks up to 50 pages of
  size 2. This is a pre-existing flaky test unrelated to this task
  and was not modified.

Replit-Task-Id: beca78bc-32f3-4cdc-8440-9a661b48363b
This commit is contained in:
riyadhafraa
2026-04-22 09:04:28 +00:00
parent a21428b2a4
commit 5de821fac8
9 changed files with 146 additions and 9 deletions
@@ -22,6 +22,11 @@ const router: IRouter = Router();
const PERM_RECEIVE = "orders.receive";
const ACTIVE_STATUSES = ["pending", "received", "preparing"] as const;
// Server-side undo window for restoring a cancelled order back to its
// prior status. Includes a small buffer over the client UI window
// (7s) to absorb network/clock skew while still making cancellation
// effectively permanent shortly after.
const RESTORE_WINDOW_MS = 15_000;
async function loadOrder(id: number) {
const [row] = await db
@@ -312,6 +317,36 @@ router.patch(
res.status(400).json({ error: "invalid_transition" });
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;
}
} else if (next === "cancelled") {
const isOwner = existing.userId === userId;
const isAssignee = existing.assignedTo === userId;
@@ -349,6 +384,8 @@ router.patch(
// assignee transition where the owner also holds the receiver role).
if (enriched) {
const titleMap: Record<string, [string, string]> = {
pending: ["تمت استعادة طلبك", "Your order was restored"],
received: ["تمت استعادة طلبك", "Your order was restored"],
preparing: ["طلبك قيد التحضير", "Your order is being prepared"],
completed: ["تم إكمال طلبك", "Your order is completed"],
cancelled: ["تم إلغاء طلبك", "Your order was cancelled"],
@@ -364,6 +364,65 @@ test("DELETE /orders/:id — owner can delete completed/cancelled, admin can del
assert.equal(res.status, 401);
});
test("owner can restore (undo) a freshly cancelled order to its prior status; expired window blocks restore", async () => {
const owner = await createUser("undoOwn", "user");
const recv = await createUser("undoRecv", "order_receiver");
const stranger = await createUser("undoStr", "user");
const co = await login(owner.username);
const cr = await login(recv.username);
const cs = await login(stranger.username);
// Case A: cancel from pending → restore to pending
let res = await call(co, "POST", "/orders", { serviceId });
assert.equal(res.status, 201);
const oA = await res.json();
createdOrderIds.push(oA.id);
res = await call(co, "PATCH", `/orders/${oA.id}/status`, { status: "cancelled" });
assert.equal(res.status, 200);
// Stranger cannot restore
res = await call(cs, "PATCH", `/orders/${oA.id}/status`, { status: "pending" });
assert.equal(res.status, 403);
// Owner restores within window
res = await call(co, "PATCH", `/orders/${oA.id}/status`, { status: "pending" });
assert.equal(res.status, 200);
assert.equal((await res.json()).status, "pending");
// Case B: cancel from received → restore to received
res = await call(co, "POST", "/orders", { serviceId });
const oB = await res.json();
createdOrderIds.push(oB.id);
res = await call(cr, "PATCH", `/orders/${oB.id}/confirm-receipt`);
assert.equal(res.status, 200);
res = await call(co, "PATCH", `/orders/${oB.id}/status`, { status: "cancelled" });
assert.equal(res.status, 200);
// Restoring to "pending" should fail because assignedTo is set
res = await call(co, "PATCH", `/orders/${oB.id}/status`, { status: "pending" });
assert.equal(res.status, 400);
// Correct restore is "received"
res = await call(co, "PATCH", `/orders/${oB.id}/status`, { status: "received" });
assert.equal(res.status, 200);
assert.equal((await res.json()).status, "received");
// Case C: undo window expired → restore is rejected
res = await call(co, "POST", "/orders", { serviceId });
const oC = await res.json();
createdOrderIds.push(oC.id);
res = await call(co, "PATCH", `/orders/${oC.id}/status`, { status: "cancelled" });
assert.equal(res.status, 200);
// Backdate updatedAt past the 15s server window.
await pool.query(
`UPDATE service_orders SET updated_at = NOW() - INTERVAL '60 seconds' WHERE id = $1`,
[oC.id],
);
res = await call(co, "PATCH", `/orders/${oC.id}/status`, { status: "pending" });
assert.equal(res.status, 400);
const body = await res.json();
assert.match(body.error, /undo_window_expired/);
});
test("unauthenticated cannot place order", async () => {
const res = await fetch(`${API_BASE}/api/orders`, {
method: "POST",
+2 -1
View File
@@ -127,11 +127,12 @@
"notesLabel": "ملاحظات:",
"cancel": "إلغاء الطلب",
"cancelConfirmTitle": "إلغاء هذا الطلب؟",
"cancelConfirmBody": "لن يكون من الممكن إعادته بعد الإلغاء.",
"cancelConfirmBody": "ستتاح لك ثوانٍ قليلة للتراجع.",
"cancelConfirm": "نعم، إلغاء",
"keep": "احتفظ به",
"cancelled": "تم إلغاء الطلب",
"cancelFailed": "تعذّر إلغاء الطلب",
"restoreFailed": "تعذّر استعادة الطلب",
"delete": "حذف",
"deleteConfirmTitle": "حذف هذا الطلب؟",
"deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.",
+2 -1
View File
@@ -127,11 +127,12 @@
"notesLabel": "Notes:",
"cancel": "Cancel order",
"cancelConfirmTitle": "Cancel this order?",
"cancelConfirmBody": "Once cancelled, it can't be reopened.",
"cancelConfirmBody": "You'll have a few seconds to undo this.",
"cancelConfirm": "Yes, cancel",
"keep": "Keep it",
"cancelled": "Order cancelled",
"cancelFailed": "Could not cancel the order",
"restoreFailed": "Could not restore the order",
"delete": "Delete",
"deleteConfirmTitle": "Delete this order?",
"deleteConfirmBody": "This will permanently remove the order from your history.",
+32 -1
View File
@@ -175,6 +175,7 @@ function OrderCard({
const deletable = isFinished(order.status);
const handleCancel = () => {
const previousStatus = order.status;
updateStatus.mutate(
{ id: order.id, data: { status: "cancelled" } },
{
@@ -182,8 +183,38 @@ function OrderCard({
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
toast({ title: t("myOrders.cancelled") });
setConfirmCancelOpen(false);
const undo = () => {
updateStatus.mutate(
{ id: order.id, data: { status: previousStatus } },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
handle.dismiss();
toast({ title: t("myOrders.undone") });
},
onError: () => {
toast({
title: t("myOrders.restoreFailed"),
variant: "destructive",
});
},
},
);
};
const handle = toast({
title: t("myOrders.cancelled"),
duration: UNDO_WINDOW_MS,
action: (
<ToastAction altText={t("myOrders.undo")} onClick={undo}>
{t("myOrders.undo")}
</ToastAction>
),
});
},
onError: () => {
toast({
@@ -391,6 +391,8 @@ export type UpdateServiceOrderStatusBodyStatus =
(typeof UpdateServiceOrderStatusBodyStatus)[keyof typeof UpdateServiceOrderStatusBodyStatus];
export const UpdateServiceOrderStatusBodyStatus = {
pending: "pending",
received: "received",
preparing: "preparing",
completed: "completed",
cancelled: "cancelled",
+2 -2
View File
@@ -2546,7 +2546,7 @@ export function useListIncomingServiceOrders<
}
/**
* @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time).
* @summary Update order status. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time); pending/received are restore-from-cancelled transitions allowed for owner or admin.
*/
export const getUpdateServiceOrderStatusUrl = (id: number) => {
return `/api/orders/${id}/status`;
@@ -2611,7 +2611,7 @@ export type UpdateServiceOrderStatusMutationBody =
export type UpdateServiceOrderStatusMutationError = ErrorType<ErrorResponse>;
/**
* @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time).
* @summary Update order status. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time); pending/received are restore-from-cancelled transitions allowed for owner or admin.
*/
export const useUpdateServiceOrderStatus = <
TError = ErrorType<ErrorResponse>,
+2 -2
View File
@@ -657,7 +657,7 @@ paths:
patch:
operationId: updateServiceOrderStatus
tags: [orders]
summary: Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time).
summary: Update order status. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time); pending/received are restore-from-cancelled transitions allowed for owner or admin.
parameters:
- name: id
in: path
@@ -2261,7 +2261,7 @@ components:
properties:
status:
type: string
enum: [preparing, completed, cancelled]
enum: [pending, received, preparing, completed, cancelled]
required:
- status
+8 -2
View File
@@ -708,14 +708,20 @@ export const ListIncomingServiceOrdersResponse = zod.array(
);
/**
* @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time).
* @summary Update order status. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time); pending/received are restore-from-cancelled transitions allowed for owner or admin.
*/
export const UpdateServiceOrderStatusParams = zod.object({
id: zod.coerce.number(),
});
export const UpdateServiceOrderStatusBody = zod.object({
status: zod.enum(["preparing", "completed", "cancelled"]),
status: zod.enum([
"pending",
"received",
"preparing",
"completed",
"cancelled",
]),
});
export const UpdateServiceOrderStatusResponse = zod.object({