diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 20a56aa5..7a3f2741 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -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 => { + 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; diff --git a/artifacts/api-server/tests/service-orders.test.mjs b/artifacts/api-server/tests/service-orders.test.mjs index f94a3630..3a4a2fbd 100644 --- a/artifacts/api-server/tests/service-orders.test.mjs +++ b/artifacts/api-server/tests/service-orders.test.mjs @@ -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", diff --git a/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts b/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts index 28360f44..d8060718 100644 --- a/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts @@ -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(), diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 5cb198cb..d69054f9 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -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": "بانتظار المستلِم", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index f9cbc8a6..eda92204 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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", diff --git a/artifacts/teaboy-os/src/pages/my-orders.tsx b/artifacts/teaboy-os/src/pages/my-orders.tsx index 6051a686..ba693b08 100644 --- a/artifacts/teaboy-os/src/pages/my-orders.tsx +++ b/artifacts/teaboy-os/src/pages/my-orders.tsx @@ -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 = { 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 (
@@ -205,21 +235,36 @@ function OrderCard({ order }: { order: ServiceOrder }) { - {cancellable && ( -
- + {(cancellable || deletable) && ( +
+ {cancellable && ( + + )} + {deletable && ( + + )}
)} - + @@ -246,6 +291,34 @@ function OrderCard({ order }: { order: ServiceOrder }) { + + + + + + {t("myOrders.deleteConfirmTitle")} + + + {t("myOrders.deleteConfirmBody")} + + + + + {t("myOrders.keep")} + + { + e.preventDefault(); + handleDelete(); + }} + disabled={deleteOrder.isPending} + className="bg-rose-600 hover:bg-rose-700 text-white" + > + {t("myOrders.deleteConfirm")} + + + +
); } @@ -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 (
@@ -319,12 +427,54 @@ export default function MyOrdersPage() {
) : (
+ {finishedCount > 0 && ( +
+ +
+ )} {sortedOrders.map((o) => ( ))}
)}
+ + + + + + {t("myOrders.clearConfirmTitle")} + + + {t("myOrders.clearConfirmBody", { count: finishedCount })} + + + + + {t("myOrders.keep")} + + { + e.preventDefault(); + handleClearFinished(); + }} + disabled={bulkPending} + className="bg-rose-600 hover:bg-rose-700 text-white" + > + {t("myOrders.clearConfirm")} + + + +
); } diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 31d917df..f7b2a1e7 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { + return customFetch(getDeleteServiceOrderUrl(id), { + ...options, + method: "DELETE", + }); +}; + +export const getDeleteServiceOrderMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { id: number } + > = (props) => { + const { id } = props ?? {}; + + return deleteServiceOrder(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteServiceOrderMutationResult = NonNullable< + Awaited> +>; + +export type DeleteServiceOrderMutationError = ErrorType; + +/** + * @summary Delete a service order + */ +export const useDeleteServiceOrder = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number }, + TContext +> => { + return useMutation(getDeleteServiceOrderMutationOptions(options)); +}; + /** * @summary Idempotently add a role to a user (admin) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 40933811..94aa6547 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 9a1fa971..c4dcf05e 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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) */