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:
@@ -22,6 +22,11 @@ const router: IRouter = Router();
|
|||||||
|
|
||||||
const PERM_RECEIVE = "orders.receive";
|
const PERM_RECEIVE = "orders.receive";
|
||||||
const ACTIVE_STATUSES = ["pending", "received", "preparing"] as const;
|
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) {
|
async function loadOrder(id: number) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -312,6 +317,36 @@ router.patch(
|
|||||||
res.status(400).json({ error: "invalid_transition" });
|
res.status(400).json({ error: "invalid_transition" });
|
||||||
return;
|
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") {
|
} else if (next === "cancelled") {
|
||||||
const isOwner = existing.userId === userId;
|
const isOwner = existing.userId === userId;
|
||||||
const isAssignee = existing.assignedTo === userId;
|
const isAssignee = existing.assignedTo === userId;
|
||||||
@@ -349,6 +384,8 @@ router.patch(
|
|||||||
// assignee transition where the owner also holds the receiver role).
|
// assignee transition where the owner also holds the receiver role).
|
||||||
if (enriched) {
|
if (enriched) {
|
||||||
const titleMap: Record<string, [string, string]> = {
|
const titleMap: Record<string, [string, string]> = {
|
||||||
|
pending: ["تمت استعادة طلبك", "Your order was restored"],
|
||||||
|
received: ["تمت استعادة طلبك", "Your order was restored"],
|
||||||
preparing: ["طلبك قيد التحضير", "Your order is being prepared"],
|
preparing: ["طلبك قيد التحضير", "Your order is being prepared"],
|
||||||
completed: ["تم إكمال طلبك", "Your order is completed"],
|
completed: ["تم إكمال طلبك", "Your order is completed"],
|
||||||
cancelled: ["تم إلغاء طلبك", "Your order was cancelled"],
|
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);
|
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 () => {
|
test("unauthenticated cannot place order", async () => {
|
||||||
const res = await fetch(`${API_BASE}/api/orders`, {
|
const res = await fetch(`${API_BASE}/api/orders`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -127,11 +127,12 @@
|
|||||||
"notesLabel": "ملاحظات:",
|
"notesLabel": "ملاحظات:",
|
||||||
"cancel": "إلغاء الطلب",
|
"cancel": "إلغاء الطلب",
|
||||||
"cancelConfirmTitle": "إلغاء هذا الطلب؟",
|
"cancelConfirmTitle": "إلغاء هذا الطلب؟",
|
||||||
"cancelConfirmBody": "لن يكون من الممكن إعادته بعد الإلغاء.",
|
"cancelConfirmBody": "ستتاح لك ثوانٍ قليلة للتراجع.",
|
||||||
"cancelConfirm": "نعم، إلغاء",
|
"cancelConfirm": "نعم، إلغاء",
|
||||||
"keep": "احتفظ به",
|
"keep": "احتفظ به",
|
||||||
"cancelled": "تم إلغاء الطلب",
|
"cancelled": "تم إلغاء الطلب",
|
||||||
"cancelFailed": "تعذّر إلغاء الطلب",
|
"cancelFailed": "تعذّر إلغاء الطلب",
|
||||||
|
"restoreFailed": "تعذّر استعادة الطلب",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"deleteConfirmTitle": "حذف هذا الطلب؟",
|
"deleteConfirmTitle": "حذف هذا الطلب؟",
|
||||||
"deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.",
|
"deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.",
|
||||||
|
|||||||
@@ -127,11 +127,12 @@
|
|||||||
"notesLabel": "Notes:",
|
"notesLabel": "Notes:",
|
||||||
"cancel": "Cancel order",
|
"cancel": "Cancel order",
|
||||||
"cancelConfirmTitle": "Cancel this 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",
|
"cancelConfirm": "Yes, cancel",
|
||||||
"keep": "Keep it",
|
"keep": "Keep it",
|
||||||
"cancelled": "Order cancelled",
|
"cancelled": "Order cancelled",
|
||||||
"cancelFailed": "Could not cancel the order",
|
"cancelFailed": "Could not cancel the order",
|
||||||
|
"restoreFailed": "Could not restore the order",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteConfirmTitle": "Delete this order?",
|
"deleteConfirmTitle": "Delete this order?",
|
||||||
"deleteConfirmBody": "This will permanently remove the order from your history.",
|
"deleteConfirmBody": "This will permanently remove the order from your history.",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ function OrderCard({
|
|||||||
const deletable = isFinished(order.status);
|
const deletable = isFinished(order.status);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
const previousStatus = order.status;
|
||||||
updateStatus.mutate(
|
updateStatus.mutate(
|
||||||
{ id: order.id, data: { status: "cancelled" } },
|
{ id: order.id, data: { status: "cancelled" } },
|
||||||
{
|
{
|
||||||
@@ -182,8 +183,38 @@ function OrderCard({
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: getListMyServiceOrdersQueryKey(),
|
queryKey: getListMyServiceOrdersQueryKey(),
|
||||||
});
|
});
|
||||||
toast({ title: t("myOrders.cancelled") });
|
|
||||||
setConfirmCancelOpen(false);
|
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: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ export type UpdateServiceOrderStatusBodyStatus =
|
|||||||
(typeof UpdateServiceOrderStatusBodyStatus)[keyof typeof UpdateServiceOrderStatusBodyStatus];
|
(typeof UpdateServiceOrderStatusBodyStatus)[keyof typeof UpdateServiceOrderStatusBodyStatus];
|
||||||
|
|
||||||
export const UpdateServiceOrderStatusBodyStatus = {
|
export const UpdateServiceOrderStatusBodyStatus = {
|
||||||
|
pending: "pending",
|
||||||
|
received: "received",
|
||||||
preparing: "preparing",
|
preparing: "preparing",
|
||||||
completed: "completed",
|
completed: "completed",
|
||||||
cancelled: "cancelled",
|
cancelled: "cancelled",
|
||||||
|
|||||||
@@ -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) => {
|
export const getUpdateServiceOrderStatusUrl = (id: number) => {
|
||||||
return `/api/orders/${id}/status`;
|
return `/api/orders/${id}/status`;
|
||||||
@@ -2611,7 +2611,7 @@ export type UpdateServiceOrderStatusMutationBody =
|
|||||||
export type UpdateServiceOrderStatusMutationError = ErrorType<ErrorResponse>;
|
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 = <
|
export const useUpdateServiceOrderStatus = <
|
||||||
TError = ErrorType<ErrorResponse>,
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ paths:
|
|||||||
patch:
|
patch:
|
||||||
operationId: updateServiceOrderStatus
|
operationId: updateServiceOrderStatus
|
||||||
tags: [orders]
|
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:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
in: path
|
in: path
|
||||||
@@ -2261,7 +2261,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [preparing, completed, cancelled]
|
enum: [pending, received, preparing, completed, cancelled]
|
||||||
required:
|
required:
|
||||||
- status
|
- status
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
export const UpdateServiceOrderStatusParams = zod.object({
|
||||||
id: zod.coerce.number(),
|
id: zod.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateServiceOrderStatusBody = zod.object({
|
export const UpdateServiceOrderStatusBody = zod.object({
|
||||||
status: zod.enum(["preparing", "completed", "cancelled"]),
|
status: zod.enum([
|
||||||
|
"pending",
|
||||||
|
"received",
|
||||||
|
"preparing",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateServiceOrderStatusResponse = zod.object({
|
export const UpdateServiceOrderStatusResponse = zod.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user