diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 035f0ad1..cc54c9bb 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -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 = { + 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"], diff --git a/artifacts/api-server/tests/service-orders.test.mjs b/artifacts/api-server/tests/service-orders.test.mjs index 9f8560b5..994d96b8 100644 --- a/artifacts/api-server/tests/service-orders.test.mjs +++ b/artifacts/api-server/tests/service-orders.test.mjs @@ -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", diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 52f475b6..c05edb2a 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -127,11 +127,12 @@ "notesLabel": "ملاحظات:", "cancel": "إلغاء الطلب", "cancelConfirmTitle": "إلغاء هذا الطلب؟", - "cancelConfirmBody": "لن يكون من الممكن إعادته بعد الإلغاء.", + "cancelConfirmBody": "ستتاح لك ثوانٍ قليلة للتراجع.", "cancelConfirm": "نعم، إلغاء", "keep": "احتفظ به", "cancelled": "تم إلغاء الطلب", "cancelFailed": "تعذّر إلغاء الطلب", + "restoreFailed": "تعذّر استعادة الطلب", "delete": "حذف", "deleteConfirmTitle": "حذف هذا الطلب؟", "deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 453f6653..c7dd42f3 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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.", diff --git a/artifacts/teaboy-os/src/pages/my-orders.tsx b/artifacts/teaboy-os/src/pages/my-orders.tsx index e3cd43a2..6ebeaf33 100644 --- a/artifacts/teaboy-os/src/pages/my-orders.tsx +++ b/artifacts/teaboy-os/src/pages/my-orders.tsx @@ -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: ( + + {t("myOrders.undo")} + + ), + }); }, onError: () => { toast({ diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 98630b86..d998e834 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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", diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 452be85a..d7e2ab42 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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; /** - * @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, diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index b924ac01..67cba5b6 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 2c89aa7f..5c4a1e5e 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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({