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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user