Task #69: Delete past orders from My Orders

- Add DELETE /api/orders/:id endpoint: owner can delete completed/cancelled
  orders; admin can delete any. 401/403/404/204 responses. Emits
  order_deleted to owner and assignee for live invalidation.
- Update OpenAPI spec with deleteServiceOrder operation; regenerate
  api-client-react + api-zod.
- Frontend: per-order trash button on completed/cancelled cards with
  confirm dialog; bulk "Clear finished (N)" button at top of list with
  confirm dialog and toast summary (handles partial failures).
- Add ar/en locale keys under myOrders for delete + clear flows
  (singular/plural variants).
- Backend tests cover all paths: pending owner=403, stranger=403,
  owner-cancelled=204, owner-completed=204, admin-pending=204, 404, 401.

All 25 service-order tests pass; the 1 failing test (admin-app-opens-
pagination) is unrelated/pre-existing.

Follow-ups proposed: #70 undo for deleted orders, #71 audit
self-notification on receiver-cancel-own-order.
This commit is contained in:
riyadhafraa
2026-04-22 06:37:01 +00:00
parent 7574357c2f
commit 58553d3d36
9 changed files with 454 additions and 16 deletions
@@ -376,4 +376,59 @@ router.patch(
},
);
// DELETE /api/orders/:id — owner can delete terminal orders; admin can delete any
router.delete(
"/orders/:id",
requireAuth,
async (req, res): Promise<void> => {
const params = ServiceOrderIdParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const userId = req.session.userId!;
const orderId = params.data.id;
const [existing] = await db
.select()
.from(serviceOrdersTable)
.where(eq(serviceOrdersTable.id, orderId));
if (!existing) {
res.status(404).json({ error: "Order not found" });
return;
}
const admin = await isAdmin(userId);
const isOwner = existing.userId === userId;
const isTerminal =
existing.status === "completed" || existing.status === "cancelled";
if (!admin && !(isOwner && isTerminal)) {
res.status(403).json({ error: "Forbidden" });
return;
}
await db
.delete(serviceOrdersTable)
.where(eq(serviceOrdersTable.id, orderId));
await emitToUser(existing.userId, "order_deleted", { id: orderId });
if (existing.assignedTo && existing.assignedTo !== existing.userId) {
await emitToUser(existing.assignedTo, "order_deleted", { id: orderId });
}
// If the deleted order was visible to receivers (pending or active),
// refresh their incoming queues.
if (
existing.status === "pending" ||
existing.status === "received" ||
existing.status === "preparing"
) {
const receivers = await getReceiverUserIds();
await broadcastIncomingChanged(receivers);
}
res.status(204).end();
},
);
export default router;
@@ -297,6 +297,70 @@ test("requester gets a distinct notification when the receiver cancels a claimed
assert.match(cancelNotif.body_ar, /استلم طلبك ولكن تم إلغاؤه لاحقاً/);
});
test("DELETE /orders/:id — owner can delete completed/cancelled, admin can delete any, others 403", async () => {
const owner = await createUser("delOwn", "user");
const recv = await createUser("delRecv", "order_receiver");
const stranger = await createUser("delStr", "user");
const adminUser = await createUser("delAdm", "admin");
const co = await login(owner.username);
const cr = await login(recv.username);
const cs = await login(stranger.username);
const ca = await login(adminUser.username);
// Helper to create an order owned by `co`
const placeOrder = async () => {
const r = await call(co, "POST", "/orders", { serviceId });
assert.equal(r.status, 201);
const o = await r.json();
createdOrderIds.push(o.id);
return o;
};
// 1) Pending order: owner cannot delete (not terminal)
const o1 = await placeOrder();
let res = await call(co, "DELETE", `/orders/${o1.id}`);
assert.equal(res.status, 403);
// 2) Stranger cannot delete a terminal order they don't own
const o2 = await placeOrder();
res = await call(co, "PATCH", `/orders/${o2.id}/status`, { status: "cancelled" });
assert.equal(res.status, 200);
res = await call(cs, "DELETE", `/orders/${o2.id}`);
assert.equal(res.status, 403);
// 3) Owner CAN delete a cancelled order they own
res = await call(co, "DELETE", `/orders/${o2.id}`);
assert.equal(res.status, 204);
// Confirm it's gone from /orders/my
res = await call(co, "GET", "/orders/my");
const list1 = await res.json();
assert.ok(!list1.find((o) => o.id === o2.id));
// 4) Owner CAN delete a completed order
const o3 = await placeOrder();
res = await call(cr, "PATCH", `/orders/${o3.id}/confirm-receipt`);
assert.equal(res.status, 200);
res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "preparing" });
assert.equal(res.status, 200);
res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "completed" });
assert.equal(res.status, 200);
res = await call(co, "DELETE", `/orders/${o3.id}`);
assert.equal(res.status, 204);
// 5) Admin CAN delete a non-terminal (pending) order
const o4 = await placeOrder();
res = await call(ca, "DELETE", `/orders/${o4.id}`);
assert.equal(res.status, 204);
// 6) 404 for unknown id
res = await call(co, "DELETE", `/orders/9999999`);
assert.equal(res.status, 404);
// 7) Unauthenticated → 401
res = await fetch(`${API_BASE}/api/orders/1`, { method: "DELETE" });
assert.equal(res.status, 401);
});
test("unauthenticated cannot place order", async () => {
const res = await fetch(`${API_BASE}/api/orders`, {
method: "POST",
@@ -44,6 +44,15 @@ export function useNotificationsSocket() {
});
});
socket.on("order_deleted", () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getListIncomingServiceOrdersQueryKey(),
});
});
socket.on("order_incoming_changed", () => {
queryClient.invalidateQueries({
queryKey: getListIncomingServiceOrdersQueryKey(),
+16 -1
View File
@@ -131,7 +131,22 @@
"cancelConfirm": "نعم، إلغاء",
"keep": "احتفظ به",
"cancelled": "تم إلغاء الطلب",
"cancelFailed": "تعذّر إلغاء الطلب"
"cancelFailed": "تعذّر إلغاء الطلب",
"delete": "حذف",
"deleteConfirmTitle": "حذف هذا الطلب؟",
"deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.",
"deleteConfirm": "نعم، احذف",
"deleted": "تم حذف الطلب",
"deleteFailed": "تعذّر حذف الطلب",
"clearFinished_one": "مسح طلب منتهٍ",
"clearFinished_other": "مسح {{count}} طلبات منتهية",
"clearConfirmTitle": "مسح الطلبات المنتهية؟",
"clearConfirmBody_one": "سيتم حذف طلب واحد منتهٍ نهائياً من سجلّك.",
"clearConfirmBody_other": "سيتم حذف {{count}} طلبات منتهية نهائياً من سجلّك.",
"clearConfirm": "نعم، امسح",
"clearedCount_one": "تم حذف طلب واحد",
"clearedCount_other": "تم حذف {{count}} طلبات",
"clearedPartial": "تم حذف {{ok}} وفشل {{fail}}"
},
"orderStatus": {
"pending": "بانتظار المستلِم",
+16 -1
View File
@@ -131,7 +131,22 @@
"cancelConfirm": "Yes, cancel",
"keep": "Keep it",
"cancelled": "Order cancelled",
"cancelFailed": "Could not cancel the order"
"cancelFailed": "Could not cancel the order",
"delete": "Delete",
"deleteConfirmTitle": "Delete this order?",
"deleteConfirmBody": "This will permanently remove the order from your history.",
"deleteConfirm": "Yes, delete",
"deleted": "Order deleted",
"deleteFailed": "Could not delete the order",
"clearFinished_one": "Clear 1 finished",
"clearFinished_other": "Clear {{count}} finished",
"clearConfirmTitle": "Clear finished orders?",
"clearConfirmBody_one": "1 finished order will be permanently removed from your history.",
"clearConfirmBody_other": "{{count}} finished orders will be permanently removed from your history.",
"clearConfirm": "Yes, clear",
"clearedCount_one": "1 order deleted",
"clearedCount_other": "{{count}} orders deleted",
"clearedPartial": "{{ok}} deleted, {{fail}} failed"
},
"orderStatus": {
"pending": "Awaiting receiver",
+164 -14
View File
@@ -6,6 +6,7 @@ import {
useListMyServiceOrders,
getListMyServiceOrdersQueryKey,
useUpdateServiceOrderStatus,
useDeleteServiceOrder,
type ServiceOrder,
type ServiceOrderStatus,
} from "@workspace/api-client-react";
@@ -19,6 +20,7 @@ import {
Flame,
PackageCheck,
XCircle,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -51,6 +53,10 @@ const STATUS_PILL: Record<ServiceOrderStatus, string> = {
cancelled: "bg-rose-100 text-rose-700",
};
function isFinished(status: ServiceOrderStatus) {
return status === "completed" || status === "cancelled";
}
function StatusPill({ status }: { status: ServiceOrderStatus }) {
const { t } = useTranslation();
return (
@@ -149,13 +155,16 @@ function OrderCard({ order }: { order: ServiceOrder }) {
const queryClient = useQueryClient();
const { toast } = useToast();
const lang = i18n.language;
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const updateStatus = useUpdateServiceOrderStatus();
const deleteOrder = useDeleteServiceOrder();
const name = lang === "ar" ? order.service.nameAr : order.service.nameEn;
const img = resolveServiceImageUrl(order.service.imageUrl);
const cancellable =
order.status === "pending" || order.status === "received";
const deletable = isFinished(order.status);
const handleCancel = () => {
updateStatus.mutate(
@@ -166,7 +175,7 @@ function OrderCard({ order }: { order: ServiceOrder }) {
queryKey: getListMyServiceOrdersQueryKey(),
});
toast({ title: t("myOrders.cancelled") });
setConfirmOpen(false);
setConfirmCancelOpen(false);
},
onError: () => {
toast({
@@ -178,6 +187,27 @@ function OrderCard({ order }: { order: ServiceOrder }) {
);
};
const handleDelete = () => {
deleteOrder.mutate(
{ id: order.id },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
toast({ title: t("myOrders.deleted") });
setConfirmDeleteOpen(false);
},
onError: () => {
toast({
title: t("myOrders.deleteFailed"),
variant: "destructive",
});
},
},
);
};
return (
<div className="glass-panel rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-start gap-3">
@@ -205,21 +235,36 @@ function OrderCard({ order }: { order: ServiceOrder }) {
<StatusTimeline status={order.status} />
{cancellable && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
onClick={() => setConfirmOpen(true)}
disabled={updateStatus.isPending}
>
{t("myOrders.cancel")}
</Button>
{(cancellable || deletable) && (
<div className="flex justify-end gap-1">
{cancellable && (
<Button
variant="ghost"
size="sm"
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
onClick={() => setConfirmCancelOpen(true)}
disabled={updateStatus.isPending}
>
{t("myOrders.cancel")}
</Button>
)}
{deletable && (
<Button
variant="ghost"
size="sm"
className="text-slate-500 hover:text-rose-700 hover:bg-rose-50"
onClick={() => setConfirmDeleteOpen(true)}
disabled={deleteOrder.isPending}
aria-label={t("myOrders.delete")}
title={t("myOrders.delete")}
>
<Trash2 size={16} />
</Button>
)}
</div>
)}
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialog open={confirmCancelOpen} onOpenChange={setConfirmCancelOpen}>
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
@@ -246,6 +291,34 @@ function OrderCard({ order }: { order: ServiceOrder }) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("myOrders.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("myOrders.deleteConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteOrder.isPending}>
{t("myOrders.keep")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={deleteOrder.isPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
{t("myOrders.deleteConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -253,9 +326,14 @@ function OrderCard({ order }: { order: ServiceOrder }) {
export default function MyOrdersPage() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
const queryClient = useQueryClient();
const { toast } = useToast();
const lang = i18n.language;
const isRtl = lang === "ar";
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const [confirmClearOpen, setConfirmClearOpen] = useState(false);
const deleteOrder = useDeleteServiceOrder();
const [bulkPending, setBulkPending] = useState(false);
const { data: orders, isLoading, isError, refetch } = useListMyServiceOrders({
query: { queryKey: getListMyServiceOrdersQueryKey() },
@@ -268,6 +346,36 @@ export default function MyOrdersPage() {
)
: [];
const finishedOrders = sortedOrders.filter((o) => isFinished(o.status));
const finishedCount = finishedOrders.length;
const handleClearFinished = async () => {
setBulkPending(true);
let okCount = 0;
let failCount = 0;
for (const o of finishedOrders) {
try {
await deleteOrder.mutateAsync({ id: o.id });
okCount += 1;
} catch {
failCount += 1;
}
}
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
setBulkPending(false);
setConfirmClearOpen(false);
if (failCount === 0) {
toast({ title: t("myOrders.clearedCount", { count: okCount }) });
} else {
toast({
title: t("myOrders.clearedPartial", { ok: okCount, fail: failCount }),
variant: "destructive",
});
}
};
return (
<div className="min-h-screen os-bg flex flex-col">
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
@@ -319,12 +427,54 @@ export default function MyOrdersPage() {
</div>
) : (
<div className="flex flex-col gap-3">
{finishedCount > 0 && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-slate-600 hover:text-rose-700 hover:bg-rose-50 gap-1.5"
onClick={() => setConfirmClearOpen(true)}
disabled={bulkPending}
>
<Trash2 size={14} />
{t("myOrders.clearFinished", { count: finishedCount })}
</Button>
</div>
)}
{sortedOrders.map((o) => (
<OrderCard key={o.id} order={o} />
))}
</div>
)}
</div>
<AlertDialog open={confirmClearOpen} onOpenChange={setConfirmClearOpen}>
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("myOrders.clearConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("myOrders.clearConfirmBody", { count: finishedCount })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkPending}>
{t("myOrders.keep")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleClearFinished();
}}
disabled={bulkPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
{t("myOrders.clearConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+88
View File
@@ -4379,6 +4379,94 @@ export const useDeleteUser = <
return useMutation(getDeleteUserMutationOptions(options));
};
/**
* Permanently deletes the order. Allowed when the caller owns the order
AND it is in a terminal status (completed/cancelled), OR the caller is
an admin (any status).
* @summary Delete a service order
*/
export const getDeleteServiceOrderUrl = (id: number) => {
return `/api/orders/${id}`;
};
export const deleteServiceOrder = async (
id: number,
options?: RequestInit,
): Promise<void> => {
return customFetch<void>(getDeleteServiceOrderUrl(id), {
...options,
method: "DELETE",
});
};
export const getDeleteServiceOrderMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceOrder>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceOrder>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["deleteServiceOrder"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceOrder>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return deleteServiceOrder(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceOrderMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceOrder>>
>;
export type DeleteServiceOrderMutationError = ErrorType<ErrorResponse>;
/**
* @summary Delete a service order
*/
export const useDeleteServiceOrder = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceOrder>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceOrder>>,
TError,
{ id: number },
TContext
> => {
return useMutation(getDeleteServiceOrderMutationOptions(options));
};
/**
* @summary Idempotently add a role to a user (admin)
*/
+31
View File
@@ -1125,6 +1125,37 @@ paths:
"204":
description: Deleted
/orders/{id}:
delete:
operationId: deleteServiceOrder
tags: [orders]
summary: Delete a service order
description: |
Permanently deletes the order. Allowed when the caller owns the order
AND it is in a terminal status (completed/cancelled), OR the caller is
an admin (any status).
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"204":
description: Deleted
"403":
description: Forbidden
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Order not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/users/{id}/roles:
post:
operationId: addUserRole
+11
View File
@@ -1405,6 +1405,17 @@ export const DeleteUserParams = zod.object({
id: zod.coerce.number(),
});
/**
* Permanently deletes the order. Allowed when the caller owns the order
AND it is in a terminal status (completed/cancelled), OR the caller is
an admin (any status).
* @summary Delete a service order
*/
export const DeleteServiceOrderParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Idempotently add a role to a user (admin)
*/