Add Undo window when receivers cancel an incoming order

Original task (#73): Mirror the existing 7s Undo toast (already in place
for owner-side cancels in My Orders) on the receiver-side cancel action
in the Incoming Orders page.

Implementation:
- artifacts/teaboy-os/src/pages/orders-incoming.tsx
  - Captures the order's previous status (received | preparing) before
    issuing the cancel PATCH.
  - On success, shows a toast with title "Order cancelled" and an
    "Undo" ToastAction. Clicking Undo PATCHes the order status back to
    the previous value, then shows a "Restored" confirmation toast.
  - UNDO_WINDOW_MS = 7000 to match the owner-side flow.
- artifacts/api-server/src/routes/service-orders.ts
  - Refactored the PATCH /orders/:id/status authorization. Detects
    "restore from cancelled" up front (existing.status === cancelled
    and next !== cancelled) and grants permission to:
      - owner/admin: may restore to pending or received (existing rule)
      - original assignee: may restore to received or preparing (NEW)
  - The same 15s server-side undo window applies.
- artifacts/teaboy-os/src/locales/{en,ar}.json
  - Added incomingOrders.cancelled / undo / undone / restoreFailed
    strings in English and Arabic.
- Regenerated lib/api-zod and lib/api-client-react via the api-spec
  codegen script (the previously-cached output was missing pending /
  received / preparing from the request body enum, which now matches
  the OpenAPI spec).

Verification:
- Typecheck passes for api-server and teaboy-os.
- e2e flow verified: cancelling a received order shows the Undo toast;
  clicking Undo restores the order to "received" (DB confirmed);
  letting the window expire leaves the order permanently "cancelled"
  (DB confirmed).

Deviations: None.
Replit-Task-Id: 65c4b053-613a-4898-a0f7-b6fb2d680301
This commit is contained in:
riyadhafraa
2026-04-22 09:58:49 +00:00
parent 36f3704d1d
commit 7d29d63d2d
5 changed files with 117 additions and 32 deletions
@@ -302,7 +302,49 @@ router.patch(
const admin = await isAdmin(userId); const admin = await isAdmin(userId);
if (next === "preparing" || next === "completed") { const isRestoreFromCancelled =
existing.status === "cancelled" && next !== "cancelled";
if (isRestoreFromCancelled) {
// Restore a cancelled order back to a prior active status (Undo).
// Owner/admin may restore to pending or received; the original
// assignee may restore to received or preparing.
const isOwner = existing.userId === userId;
const isAssignee = existing.assignedTo === userId;
const canOwnerRestore =
(isOwner || admin) && (next === "pending" || next === "received");
const canAssigneeRestore =
isAssignee && (next === "received" || next === "preparing");
if (!canOwnerRestore && !canAssigneeRestore) {
if (!isOwner && !isAssignee && !admin) {
res.status(403).json({ error: "Forbidden" });
return;
}
res.status(400).json({ error: "invalid_transition" });
return;
}
if (next === "pending" && existing.assignedTo !== null) {
res.status(400).json({ error: "invalid_transition" });
return;
}
if (
(next === "received" || next === "preparing") &&
existing.assignedTo === null
) {
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;
}
} else if (next === "preparing" || next === "completed") {
// Only the assigned receiver or admin // Only the assigned receiver or admin
const isAssignee = existing.assignedTo === userId; const isAssignee = existing.assignedTo === userId;
if (!isAssignee && !admin) { if (!isAssignee && !admin) {
@@ -318,35 +360,10 @@ router.patch(
return; return;
} }
} else if (next === "pending" || next === "received") { } else if (next === "pending" || next === "received") {
// Restore a cancelled order back to its prior status (Undo). // Restore is handled above; outside cancelled, these targets are
// Allowed for the owner or admin only. // never valid via this endpoint.
const isOwner = existing.userId === userId; res.status(400).json({ error: "invalid_transition" });
if (!isOwner && !admin) { return;
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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+5 -1
View File
@@ -182,7 +182,11 @@
"alreadyClaimed": "استلمه شخص آخر قبلك", "alreadyClaimed": "استلمه شخص آخر قبلك",
"actionFailed": "تعذّر تنفيذ الإجراء", "actionFailed": "تعذّر تنفيذ الإجراء",
"claimed": "تم استلام الطلب", "claimed": "تم استلام الطلب",
"marked": "تم تحديث الحالة" "marked": "تم تحديث الحالة",
"cancelled": "تم إلغاء الطلب",
"undo": "تراجع",
"undone": "تمت الاستعادة",
"restoreFailed": "تعذّر استعادة الطلب"
}, },
"chat": { "chat": {
"title": "المحادثات", "title": "المحادثات",
+5 -1
View File
@@ -182,7 +182,11 @@
"alreadyClaimed": "Already claimed by someone else", "alreadyClaimed": "Already claimed by someone else",
"actionFailed": "Could not complete the action", "actionFailed": "Could not complete the action",
"claimed": "Order received", "claimed": "Order received",
"marked": "Status updated" "marked": "Status updated",
"cancelled": "Order cancelled",
"undo": "Undo",
"undone": "Restored",
"restoreFailed": "Could not restore the order"
}, },
"chat": { "chat": {
"title": "Chat", "title": "Chat",
@@ -22,6 +22,7 @@ import {
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ToastAction } from "@/components/ui/toast";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { resolveServiceImageUrl } from "@/lib/image-url"; import { resolveServiceImageUrl } from "@/lib/image-url";
@@ -29,6 +30,8 @@ import { cn } from "@/lib/utils";
export const ORDER_RECEIVER_ROLE = "order_receiver"; export const ORDER_RECEIVER_ROLE = "order_receiver";
const UNDO_WINDOW_MS = 7000;
export function userCanReceiveOrders(roles: string[] | undefined | null): boolean { export function userCanReceiveOrders(roles: string[] | undefined | null): boolean {
if (!roles) return false; if (!roles) return false;
return roles.includes("admin") || roles.includes(ORDER_RECEIVER_ROLE); return roles.includes("admin") || roles.includes(ORDER_RECEIVER_ROLE);
@@ -136,6 +139,10 @@ function IncomingOrderCard({
}; };
const handleSetStatus = (status: "preparing" | "completed" | "cancelled") => { const handleSetStatus = (status: "preparing" | "completed" | "cancelled") => {
if (status === "cancelled") {
handleCancel();
return;
}
updateStatus.mutate( updateStatus.mutate(
{ id: order.id, data: { status } }, { id: order.id, data: { status } },
{ {
@@ -153,6 +160,59 @@ function IncomingOrderCard({
); );
}; };
const handleCancel = () => {
const previousStatus = order.status;
if (previousStatus !== "received" && previousStatus !== "preparing") {
return;
}
updateStatus.mutate(
{ id: order.id, data: { status: "cancelled" } },
{
onSuccess: () => {
invalidate();
const undo = () => {
updateStatus.mutate(
{ id: order.id, data: { status: previousStatus } },
{
onSuccess: () => {
invalidate();
handle.dismiss();
toast({ title: t("incomingOrders.undone") });
},
onError: () => {
toast({
title: t("incomingOrders.restoreFailed"),
variant: "destructive",
});
},
},
);
};
const handle = toast({
title: t("incomingOrders.cancelled"),
duration: UNDO_WINDOW_MS,
action: (
<ToastAction
altText={t("incomingOrders.undo")}
onClick={undo}
>
{t("incomingOrders.undo")}
</ToastAction>
),
});
},
onError: () => {
toast({
title: t("incomingOrders.actionFailed"),
variant: "destructive",
});
},
},
);
};
return ( return (
<div className="glass-panel rounded-xl p-3 flex flex-col gap-3"> <div className="glass-panel rounded-xl p-3 flex flex-col gap-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">