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:
@@ -302,7 +302,49 @@ router.patch(
|
||||
|
||||
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
|
||||
const isAssignee = existing.assignedTo === userId;
|
||||
if (!isAssignee && !admin) {
|
||||
@@ -318,35 +360,10 @@ router.patch(
|
||||
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;
|
||||
}
|
||||
// Restore is handled above; outside cancelled, these targets are
|
||||
// never valid via this endpoint.
|
||||
res.status(400).json({ error: "invalid_transition" });
|
||||
return;
|
||||
} else if (next === "cancelled") {
|
||||
const isOwner = existing.userId === userId;
|
||||
const isAssignee = existing.assignedTo === userId;
|
||||
|
||||
Reference in New Issue
Block a user