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.
This commit is contained in:
Riyadh
2026-04-22 09:04:28 +00:00
parent df34a68ea4
commit 84239cbc43
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",