From d62e67af96a16acec15627bf5ac141cef7f71b55 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 5 May 2026 13:35:21 +0000 Subject: [PATCH] Task #397: Per-card and bulk select+delete on Incoming Orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (artifacts/api-server): - Added hasReceivePermission() helper and refactored DELETE /orders/:id into shared authorizeAndDeleteOrder() so single and bulk paths use the same authorization rules. - Added POST /orders/bulk-delete returning { deletedIds, failedIds } with per-id authorization, dedup, and best-effort partial success. - Tightened receiver authorization (per code review): receivers may only delete pending/received/preparing orders; terminal (completed/cancelled) orders remain the owner's cleanup responsibility. OpenAPI description and the implementation now agree. API spec (lib/api-spec/openapi.yaml): - New /orders/bulk-delete operation with BulkDeleteServiceOrdersBody and BulkDeleteServiceOrdersResponse schemas; updated DELETE /orders/{id} description. Regenerated react-query hooks via codegen — useBulkDelete ServiceOrders is now available. UI (artifacts/tx-os/src/pages/orders-incoming.tsx): - Selection mode toggle in the page header (RTL-safe). - Per-card Checkbox plus a Select-all control. - Sticky bottom action bar with destructive Delete and selected count. - AlertDialog confirmation using pluralized i18n keys; toast surfaces partial-failure results when some ids couldn't be deleted. i18n: New incomingOrders keys in ar.json and en.json (select, selectAll, clearSelection, selectedCount, delete, deleteConfirmTitle/Body, deleted, deleteFailed, deletePartial) with pluralization. Tests: Added two new tests in service-orders.test.mjs covering receiver delete on incoming queue, receiver forbidden on terminal orders, and bulk-delete with mixed authorized / unknown / dedup / unauthenticated cases. Both pass. Pre-existing executive-meetings PDF/font test failures are unrelated. --- .../api-server/src/routes/service-orders.ts | 165 ++++++--- .../api-server/tests/service-orders.test.mjs | 124 +++++++ artifacts/tx-os/src/locales/ar.json | 15 +- artifacts/tx-os/src/locales/en.json | 15 +- artifacts/tx-os/src/pages/orders-incoming.tsx | 326 +++++++++++++++--- .../src/generated/api.schemas.ts | 15 + lib/api-client-react/src/generated/api.ts | 104 +++++- lib/api-spec/openapi.yaml | 66 +++- lib/api-zod/src/generated/api.ts | 32 +- 9 files changed, 764 insertions(+), 98 deletions(-) diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 5393983e..6fd20ba2 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -16,6 +16,7 @@ import { CreateServiceOrderBody, UpdateServiceOrderStatusParams as ServiceOrderIdParams, UpdateServiceOrderStatusBody, + BulkDeleteServiceOrdersBody, } from "@workspace/api-zod"; const router: IRouter = Router(); @@ -91,6 +92,17 @@ async function isAdmin(userId: number): Promise { return rows.length > 0; } +async function hasReceivePermission(userId: number): Promise { + const rows = await db + .select({ id: permissionsTable.id }) + .from(userRolesTable) + .innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId)) + .innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id)) + .where(and(eq(userRolesTable.userId, userId), eq(permissionsTable.name, PERM_RECEIVE))) + .limit(1); + return rows.length > 0; +} + async function emitToUser(userId: number, event: string, payload?: unknown) { const { io } = await import("../index.js"); io.to(`user:${userId}`).emit(event, payload); @@ -460,7 +472,69 @@ router.patch( }, ); -// DELETE /api/orders/:id — owner can delete terminal orders; admin can delete any +// Authorization + deletion for a single order, shared between the +// per-id DELETE and the bulk-delete endpoint. Returns a discriminated +// result the callers can map to HTTP status codes. +type DeleteOutcome = + | { ok: true } + | { ok: false; reason: "not_found" | "forbidden" }; + +async function authorizeAndDeleteOrder( + orderId: number, + actorId: number, + flags: { admin: boolean; receiver: boolean }, +): Promise { + const [existing] = await db + .select() + .from(serviceOrdersTable) + .where(eq(serviceOrdersTable.id, orderId)); + if (!existing) return { ok: false, reason: "not_found" }; + + const isOwner = existing.userId === actorId; + const isTerminal = + existing.status === "completed" || existing.status === "cancelled"; + // Receivers can only delete orders that appear in the incoming queue. + // Terminal orders (completed/cancelled) are not theirs to clean up. + const isIncomingQueue = + existing.status === "pending" || + existing.status === "received" || + existing.status === "preparing"; + + // Allowed when: + // - admin (any order, any status) + // - owner of a terminal order (completed/cancelled) + // - holder of orders.receive permission, but only for orders currently in + // the incoming queue (pending/received/preparing) — matches the page + // they can see and prevents privilege escalation against terminal orders. + const allowed = + flags.admin || + (isOwner && isTerminal) || + (flags.receiver && isIncomingQueue); + if (!allowed) return { ok: false, reason: "forbidden" }; + + 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); + } + return { ok: true }; +} + +// DELETE /api/orders/:id — owner can delete terminal orders; admin can delete +// any; users with `orders.receive` can delete any incoming-queue order. router.delete( "/orders/:id", requireAuth, @@ -471,48 +545,61 @@ router.delete( 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" }); + const [admin, receiver] = await Promise.all([ + isAdmin(userId), + hasReceivePermission(userId), + ]); + const result = await authorizeAndDeleteOrder(params.data.id, userId, { + admin, + receiver, + }); + if (!result.ok) { + if (result.reason === "not_found") { + res.status(404).json({ error: "Order not found" }); + } else { + res.status(403).json({ error: "Forbidden" }); + } 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(); }, ); +// POST /api/orders/bulk-delete — delete several orders in one round-trip. +// Authorization is enforced **per id** using the same rules as the per-id +// DELETE — there is no batching shortcut. Returns a summary the UI can use +// to surface partial failures. +router.post( + "/orders/bulk-delete", + requireAuth, + async (req, res): Promise => { + const parsed = BulkDeleteServiceOrdersBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const userId = req.session.userId!; + // Dedupe ids so we don't double-emit on accidental repeats. + const ids = Array.from(new Set(parsed.data.ids)); + + const [admin, receiver] = await Promise.all([ + isAdmin(userId), + hasReceivePermission(userId), + ]); + + const deletedIds: number[] = []; + const failedIds: number[] = []; + for (const id of ids) { + const result = await authorizeAndDeleteOrder(id, userId, { + admin, + receiver, + }); + if (result.ok) deletedIds.push(id); + else failedIds.push(id); + } + + res.json({ deletedIds, failedIds }); + }, +); + export default router; diff --git a/artifacts/api-server/tests/service-orders.test.mjs b/artifacts/api-server/tests/service-orders.test.mjs index fe5a45dc..0469ef0b 100644 --- a/artifacts/api-server/tests/service-orders.test.mjs +++ b/artifacts/api-server/tests/service-orders.test.mjs @@ -364,6 +364,130 @@ test("DELETE /orders/:id — owner can delete completed/cancelled, admin can del assert.equal(res.status, 401); }); +test("DELETE /orders/:id — receivers can delete any incoming-queue order (pending/received/preparing); non-receiver, non-owner still 403", async () => { + const owner = await createUser("recvDelOwn", "user"); + const recv = await createUser("recvDelRecv", "order_receiver"); + const stranger = await createUser("recvDelStr", "user"); + const co = await login(owner.username); + const cr = await login(recv.username); + const cs = await login(stranger.username); + + const place = 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) Receiver can delete a pending, unclaimed order. + const o1 = await place(); + let res = await call(cr, "DELETE", `/orders/${o1.id}`); + assert.equal(res.status, 204, "receiver should be able to delete pending order"); + + // 2) Receiver can delete a received order (claimed by them). + const o2 = await place(); + res = await call(cr, "PATCH", `/orders/${o2.id}/confirm-receipt`); + assert.equal(res.status, 200); + res = await call(cr, "DELETE", `/orders/${o2.id}`); + assert.equal(res.status, 204); + + // 3) Receiver can delete a preparing order assigned to a different receiver. + const otherRecv = await createUser("recvDelOther", "order_receiver"); + const cor = await login(otherRecv.username); + const o3 = await place(); + res = await call(cor, "PATCH", `/orders/${o3.id}/confirm-receipt`); + assert.equal(res.status, 200); + res = await call(cor, "PATCH", `/orders/${o3.id}/status`, { status: "preparing" }); + assert.equal(res.status, 200); + res = await call(cr, "DELETE", `/orders/${o3.id}`); + assert.equal(res.status, 204); + + // 4) Non-receiver, non-owner still gets 403 on any state. + const o4 = await place(); + res = await call(cs, "DELETE", `/orders/${o4.id}`); + assert.equal(res.status, 403); + + // 5) Receiver may NOT delete a terminal (completed/cancelled) order — that + // is the owner's cleanup responsibility, not the receiver's queue. + const o5 = await place(); + res = await call(cr, "PATCH", `/orders/${o5.id}/confirm-receipt`); + assert.equal(res.status, 200); + res = await call(cr, "PATCH", `/orders/${o5.id}/status`, { status: "preparing" }); + assert.equal(res.status, 200); + res = await call(cr, "PATCH", `/orders/${o5.id}/status`, { status: "completed" }); + assert.equal(res.status, 200); + res = await call(cr, "DELETE", `/orders/${o5.id}`); + assert.equal(res.status, 403, "receiver should NOT delete completed orders"); + + const o6 = await place(); + res = await call(co, "PATCH", `/orders/${o6.id}/status`, { status: "cancelled" }); + assert.equal(res.status, 200); + res = await call(cr, "DELETE", `/orders/${o6.id}`); + assert.equal(res.status, 403, "receiver should NOT delete cancelled orders"); +}); + +test("POST /orders/bulk-delete — deletes authorized ids, reports failures, dedupes", async () => { + const owner = await createUser("bulkOwn", "user"); + const recv = await createUser("bulkRecv", "order_receiver"); + const stranger = await createUser("bulkStr", "user"); + const co = await login(owner.username); + const cr = await login(recv.username); + const cs = await login(stranger.username); + + const place = 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; + }; + + // Receiver deletes 3 ids; one is unknown → reported as failed; ids deduped. + const a = await place(); + const b = await place(); + const c = await place(); + const unknownId = 99999991; + + let res = await call(cr, "POST", "/orders/bulk-delete", { + ids: [a.id, b.id, c.id, c.id, unknownId], + }); + assert.equal(res.status, 200); + let body = await res.json(); + assert.deepEqual(body.deletedIds.sort(), [a.id, b.id, c.id].sort()); + assert.deepEqual(body.failedIds, [unknownId]); + + // Stranger tries to bulk-delete owner's pending orders → all rejected. + const d = await place(); + const e = await place(); + res = await call(cs, "POST", "/orders/bulk-delete", { ids: [d.id, e.id] }); + assert.equal(res.status, 200); + body = await res.json(); + assert.deepEqual(body.deletedIds, []); + assert.deepEqual(body.failedIds.sort(), [d.id, e.id].sort()); + + // Owner can bulk-delete only the cancelled one of two; pending stays. + res = await call(co, "PATCH", `/orders/${d.id}/status`, { status: "cancelled" }); + assert.equal(res.status, 200); + res = await call(co, "POST", "/orders/bulk-delete", { ids: [d.id, e.id] }); + assert.equal(res.status, 200); + body = await res.json(); + assert.deepEqual(body.deletedIds, [d.id]); + assert.deepEqual(body.failedIds, [e.id]); + + // Validation: empty ids → 400 + res = await call(cr, "POST", "/orders/bulk-delete", { ids: [] }); + assert.equal(res.status, 400); + + // Unauthenticated → 401 + res = await fetch(`${API_BASE}/api/orders/bulk-delete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: [1] }), + }); + 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"); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index a3279fc6..0ac5be68 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -190,7 +190,20 @@ "cancelled": "تم إلغاء الطلب", "undo": "تراجع", "undone": "تمت الاستعادة", - "restoreFailed": "تعذّر استعادة الطلب" + "restoreFailed": "تعذّر استعادة الطلب", + "select": "تحديد", + "selectAll": "تحديد الكل", + "clearSelection": "إلغاء التحديد", + "selectedCount_one": "تم تحديد طلب واحد", + "selectedCount_other": "تم تحديد {{count}} طلبات", + "delete": "حذف", + "deleteConfirmTitle": "حذف الطلبات؟", + "deleteConfirmBody_one": "سيتم حذف طلب واحد نهائياً. لا يمكن التراجع.", + "deleteConfirmBody_other": "سيتم حذف {{count}} طلبات نهائياً. لا يمكن التراجع.", + "deleted_one": "تم حذف طلب واحد", + "deleted_other": "تم حذف {{count}} طلبات", + "deleteFailed": "تعذّر حذف الطلبات", + "deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي" }, "chat": { "title": "المحادثات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index d191cf26..0982fb70 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -190,7 +190,20 @@ "cancelled": "Order cancelled", "undo": "Undo", "undone": "Restored", - "restoreFailed": "Could not restore the order" + "restoreFailed": "Could not restore the order", + "select": "Select", + "selectAll": "Select all", + "clearSelection": "Clear selection", + "selectedCount_one": "1 order selected", + "selectedCount_other": "{{count}} orders selected", + "delete": "Delete", + "deleteConfirmTitle": "Delete orders?", + "deleteConfirmBody_one": "1 order will be permanently deleted. This can't be undone.", + "deleteConfirmBody_other": "{{count}} orders will be permanently deleted. This can't be undone.", + "deleted_one": "1 order deleted", + "deleted_other": "{{count}} orders deleted", + "deleteFailed": "Could not delete the orders", + "deletePartial": "Deleted {{deleted}} of {{total}}; the rest could not be deleted" }, "chat": { "title": "Chat", diff --git a/artifacts/tx-os/src/pages/orders-incoming.tsx b/artifacts/tx-os/src/pages/orders-incoming.tsx index ac17f1d0..8c4eea04 100644 --- a/artifacts/tx-os/src/pages/orders-incoming.tsx +++ b/artifacts/tx-os/src/pages/orders-incoming.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { useQueryClient } from "@tanstack/react-query"; @@ -7,6 +7,7 @@ import { getListIncomingServiceOrdersQueryKey, useConfirmServiceOrderReceipt, useUpdateServiceOrderStatus, + useBulkDeleteServiceOrders, type ServiceOrder, } from "@workspace/api-client-react"; import { @@ -20,9 +21,21 @@ import { PackageCheck, XCircle, Loader2, + Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { ToastAction } from "@/components/ui/toast"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { useAuth } from "@/contexts/AuthContext"; import { useToast } from "@/hooks/use-toast"; import { resolveServiceImageUrl } from "@/lib/image-url"; @@ -80,10 +93,16 @@ function IncomingOrderCard({ order, meId, onActionDone, + selectionMode, + selected, + onToggleSelected, }: { order: ServiceOrder; meId: number; onActionDone: () => void; + selectionMode: boolean; + selected: boolean; + onToggleSelected: (id: number) => void; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -247,8 +266,26 @@ function IncomingOrderCard({ }; return ( -
+
onToggleSelected(order.id) : undefined} + data-testid={`incoming-order-card-${order.id}`} + >
+ {selectionMode && ( +
e.stopPropagation()}> + onToggleSelected(order.id)} + aria-label={t("incomingOrders.select")} + data-testid={`order-select-${order.id}`} + /> +
+ )}
@@ -272,50 +309,52 @@ function IncomingOrderCard({
-
- {order.status === "pending" && !order.assignedTo && ( - - )} - - {isMine && order.status === "received" && ( - - )} - - {isMine && order.status === "preparing" && ( - - )} - - {isMine && - (order.status === "received" || order.status === "preparing") && ( - )} -
+ + {isMine && order.status === "received" && ( + + )} + + {isMine && order.status === "preparing" && ( + + )} + + {isMine && + (order.status === "received" || order.status === "preparing") && ( + + )} +
+ )}
); } @@ -324,6 +363,8 @@ export default function OrdersIncomingPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); const { user } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); const lang = i18n.language; const isRtl = lang === "ar"; const BackIcon = isRtl ? ArrowRight : ArrowLeft; @@ -346,6 +387,99 @@ export default function OrdersIncomingPage() { ); const mine = sorted.filter((o) => o.assignedTo === meId); const unclaimed = sorted.filter((o) => o.status === "pending" && !o.assignedTo); + const allVisibleIds = useMemo( + () => [...mine, ...unclaimed].map((o) => o.id), + [mine, unclaimed], + ); + + const [selectionMode, setSelectionMode] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [confirmOpen, setConfirmOpen] = useState(false); + + const bulkDelete = useBulkDeleteServiceOrders(); + + // Drop ids that are no longer visible (e.g. another receiver claimed them + // and they fell out of our queue) so the count stays truthful. + const validSelected = useMemo(() => { + const visible = new Set(allVisibleIds); + const next = new Set(); + for (const id of selected) if (visible.has(id)) next.add(id); + return next; + }, [selected, allVisibleIds]); + + const selectedCount = validSelected.size; + const allSelected = + allVisibleIds.length > 0 && selectedCount === allVisibleIds.length; + + const exitSelection = () => { + setSelectionMode(false); + setSelected(new Set()); + }; + + const toggleOne = (id: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (allSelected) { + setSelected(new Set()); + } else { + setSelected(new Set(allVisibleIds)); + } + }; + + const performDelete = () => { + const ids = Array.from(validSelected); + if (ids.length === 0) { + setConfirmOpen(false); + return; + } + bulkDelete.mutate( + { data: { ids } }, + { + onSuccess: (resp) => { + const deleted = resp.deletedIds.length; + const failed = resp.failedIds.length; + if (deleted > 0 && failed === 0) { + toast({ + title: t("incomingOrders.deleted", { count: deleted }), + }); + } else if (deleted > 0 && failed > 0) { + toast({ + title: t("incomingOrders.deletePartial", { + deleted, + total: ids.length, + }), + variant: "destructive", + }); + } else { + toast({ + title: t("incomingOrders.deleteFailed"), + variant: "destructive", + }); + } + setConfirmOpen(false); + exitSelection(); + queryClient.invalidateQueries({ + queryKey: getListIncomingServiceOrdersQueryKey(), + }); + refetch(); + }, + onError: () => { + toast({ + title: t("incomingOrders.deleteFailed"), + variant: "destructive", + }); + setConfirmOpen(false); + }, + }, + ); + }; return (
@@ -357,15 +491,36 @@ export default function OrdersIncomingPage() { > -
- -

+
+ +

{t("incomingOrders.title")}

+ {allowed && allVisibleIds.length > 0 && ( + selectionMode ? ( + + ) : ( + + ) + )}

-
+
{!allowed ? (
@@ -398,6 +553,18 @@ export default function OrdersIncomingPage() {
) : (
+ {selectionMode && ( + + )} + {mine.length > 0 && (

@@ -410,6 +577,9 @@ export default function OrdersIncomingPage() { order={o} meId={meId} onActionDone={refetch} + selectionMode={selectionMode} + selected={validSelected.has(o.id)} + onToggleSelected={toggleOne} /> ))}

@@ -427,6 +597,9 @@ export default function OrdersIncomingPage() { order={o} meId={meId} onActionDone={refetch} + selectionMode={selectionMode} + selected={validSelected.has(o.id)} + onToggleSelected={toggleOne} /> ))}
@@ -435,6 +608,63 @@ export default function OrdersIncomingPage() {
)}
+ + {selectionMode && selectedCount > 0 && ( +
+
+ {t("incomingOrders.selectedCount", { count: selectedCount })} +
+ +
+ )} + + + + + + {t("incomingOrders.deleteConfirmTitle")} + + + {t("incomingOrders.deleteConfirmBody", { count: selectedCount })} + + + + + {t("common.cancel", { defaultValue: t("incomingOrders.clearSelection") })} + + { + e.preventDefault(); + performDelete(); + }} + disabled={bulkDelete.isPending} + className="bg-rose-600 hover:bg-rose-700 text-white" + data-testid="confirm-delete" + > + {bulkDelete.isPending && ( + + )} + {t("incomingOrders.delete")} + + + +
); } diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index f9c3e9a4..c06c018d 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -622,6 +622,21 @@ export interface UpdateServiceOrderStatusBody { status: UpdateServiceOrderStatusBodyStatus; } +export interface BulkDeleteServiceOrdersBody { + /** + * @minItems 1 + * @maxItems 200 + */ + ids: number[]; +} + +export interface BulkDeleteServiceOrdersResponse { + /** Ids that were successfully deleted. */ + deletedIds: number[]; + /** Ids that were skipped (not found, or caller is not authorized). */ + failedIds: number[]; +} + export interface ParticipantInfo { id: number; username: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 58902c73..a285e763 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -35,6 +35,8 @@ import type { AppSettings, AuditLogList, AuthUser, + BulkDeleteServiceOrdersBody, + BulkDeleteServiceOrdersResponse, ConversationWithDetails, CreateAppBody, CreateConversationBody, @@ -4962,9 +4964,105 @@ export const useDeleteUser = < }; /** - * 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). + * Deletes several orders in one round-trip. Authorization is still +enforced per id using the same rules as DELETE /orders/{id}, so the +client cannot bypass the permission check by batching. Returns a +summary of which ids were deleted and which were rejected. + + * @summary Delete multiple service orders in one request + */ +export const getBulkDeleteServiceOrdersUrl = () => { + return `/api/orders/bulk-delete`; +}; + +export const bulkDeleteServiceOrders = async ( + bulkDeleteServiceOrdersBody: BulkDeleteServiceOrdersBody, + options?: RequestInit, +): Promise => { + return customFetch( + getBulkDeleteServiceOrdersUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(bulkDeleteServiceOrdersBody), + }, + ); +}; + +export const getBulkDeleteServiceOrdersMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["bulkDeleteServiceOrders"]; + 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>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return bulkDeleteServiceOrders(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type BulkDeleteServiceOrdersMutationResult = NonNullable< + Awaited> +>; +export type BulkDeleteServiceOrdersMutationBody = + BodyType; +export type BulkDeleteServiceOrdersMutationError = ErrorType; + +/** + * @summary Delete multiple service orders in one request + */ +export const useBulkDeleteServiceOrders = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getBulkDeleteServiceOrdersMutationOptions(options)); +}; + +/** + * Permanently deletes the order. Allowed when the caller is an admin, +when the caller owns the order AND it is in a terminal status +(completed/cancelled), OR when the caller holds the `orders.receive` +permission (i.e. a receiver clearing items from their incoming queue). * @summary Delete a service order */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index f92c23db..6434aaac 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1350,15 +1350,46 @@ paths: schema: $ref: "#/components/schemas/UserDeletionConflict" + /orders/bulk-delete: + post: + operationId: bulkDeleteServiceOrders + tags: [orders] + summary: Delete multiple service orders in one request + description: | + Deletes several orders in one round-trip. Authorization is still + enforced per id using the same rules as DELETE /orders/{id}, so the + client cannot bypass the permission check by batching. Returns a + summary of which ids were deleted and which were rejected. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BulkDeleteServiceOrdersBody" + responses: + "200": + description: Bulk delete summary + content: + application/json: + schema: + $ref: "#/components/schemas/BulkDeleteServiceOrdersResponse" + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /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). + Permanently deletes the order. Allowed when the caller is an admin, + when the caller owns the order AND it is in a terminal status + (completed/cancelled), OR when the caller holds the `orders.receive` + permission (i.e. a receiver clearing items from their incoming queue). parameters: - name: id in: path @@ -3946,6 +3977,35 @@ components: required: - status + BulkDeleteServiceOrdersBody: + type: object + properties: + ids: + type: array + items: + type: integer + minItems: 1 + maxItems: 200 + required: + - ids + + BulkDeleteServiceOrdersResponse: + type: object + properties: + deletedIds: + type: array + items: + type: integer + description: Ids that were successfully deleted. + failedIds: + type: array + items: + type: integer + description: Ids that were skipped (not found, or caller is not authorized). + required: + - deletedIds + - failedIds + ConversationWithDetails: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 2667aa53..7b24702a 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -2000,9 +2000,35 @@ export const DeleteUserQueryParams = zod.object({ }); /** - * 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). + * Deletes several orders in one round-trip. Authorization is still +enforced per id using the same rules as DELETE /orders/{id}, so the +client cannot bypass the permission check by batching. Returns a +summary of which ids were deleted and which were rejected. + + * @summary Delete multiple service orders in one request + */ +export const bulkDeleteServiceOrdersBodyIdsMax = 200; + +export const BulkDeleteServiceOrdersBody = zod.object({ + ids: zod.array(zod.number()).min(1).max(bulkDeleteServiceOrdersBodyIdsMax), +}); + +export const BulkDeleteServiceOrdersResponse = zod.object({ + deletedIds: zod + .array(zod.number()) + .describe("Ids that were successfully deleted."), + failedIds: zod + .array(zod.number()) + .describe( + "Ids that were skipped (not found, or caller is not authorized).", + ), +}); + +/** + * Permanently deletes the order. Allowed when the caller is an admin, +when the caller owns the order AND it is in a terminal status +(completed/cancelled), OR when the caller holds the `orders.receive` +permission (i.e. a receiver clearing items from their incoming queue). * @summary Delete a service order */