Task #397: Per-card and bulk select+delete on Incoming Orders

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.
This commit is contained in:
riyadhafraa
2026-05-05 13:35:21 +00:00
parent 19fb83b389
commit d62e67af96
9 changed files with 764 additions and 98 deletions
+109 -22
View File
@@ -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<boolean> {
return rows.length > 0;
}
async function hasReceivePermission(userId: number): Promise<boolean> {
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,37 +472,45 @@ 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;
// 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<DeleteOutcome> {
const [existing] = await db
.select()
.from(serviceOrdersTable)
.where(eq(serviceOrdersTable.id, orderId));
if (!existing) {
res.status(404).json({ error: "Order not found" });
return;
}
if (!existing) return { ok: false, reason: "not_found" };
const admin = await isAdmin(userId);
const isOwner = existing.userId === userId;
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";
if (!admin && !(isOwner && isTerminal)) {
res.status(403).json({ error: "Forbidden" });
return;
}
// 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)
@@ -510,9 +530,76 @@ router.delete(
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,
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 [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;
}
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<void> => {
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;
@@ -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");
+14 -1
View File
@@ -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": "المحادثات",
+14 -1
View File
@@ -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",
+236 -6
View File
@@ -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 (
<div className="glass-panel rounded-xl p-3 flex flex-col gap-3">
<div
className={cn(
"glass-panel rounded-xl p-3 flex flex-col gap-3 transition-colors",
selectionMode && "cursor-pointer",
selected && "ring-2 ring-amber-400 bg-amber-50/40",
)}
onClick={selectionMode ? () => onToggleSelected(order.id) : undefined}
data-testid={`incoming-order-card-${order.id}`}
>
<div className="flex items-start gap-3">
{selectionMode && (
<div className="pt-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected}
onCheckedChange={() => onToggleSelected(order.id)}
aria-label={t("incomingOrders.select")}
data-testid={`order-select-${order.id}`}
/>
</div>
)}
<OrderImage src={img} alt={serviceName} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
@@ -272,6 +309,7 @@ function IncomingOrderCard({
</div>
</div>
{!selectionMode && (
<div className="flex flex-wrap gap-2 justify-end">
{order.status === "pending" && !order.assignedTo && (
<Button size="sm" onClick={handleConfirmReceipt} disabled={busy}>
@@ -316,6 +354,7 @@ function IncomingOrderCard({
</Button>
)}
</div>
)}
</div>
);
}
@@ -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<Set<number>>(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<number>();
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 (
<div className="min-h-screen os-bg flex flex-col">
@@ -357,15 +491,36 @@ export default function OrdersIncomingPage() {
>
<BackIcon size={20} />
</button>
<div className="flex items-center gap-2">
<Inbox size={20} className="text-amber-500" />
<h1 className="text-lg font-semibold text-foreground">
<div className="flex items-center gap-2 flex-1 min-w-0">
<Inbox size={20} className="text-amber-500 shrink-0" />
<h1 className="text-lg font-semibold text-foreground truncate">
{t("incomingOrders.title")}
</h1>
</div>
{allowed && allVisibleIds.length > 0 && (
selectionMode ? (
<Button
variant="ghost"
size="sm"
onClick={exitSelection}
data-testid="cancel-selection"
>
{t("incomingOrders.clearSelection")}
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectionMode(true)}
data-testid="enter-selection"
>
{t("incomingOrders.select")}
</Button>
)
)}
</div>
<div className="flex-1 p-4 pb-8 max-w-2xl mx-auto w-full">
<div className="flex-1 p-4 pb-24 max-w-2xl mx-auto w-full">
{!allowed ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<Inbox size={36} className="text-slate-300" />
@@ -398,6 +553,18 @@ export default function OrdersIncomingPage() {
</div>
) : (
<div className="flex flex-col gap-6">
{selectionMode && (
<label className="flex items-center gap-2 text-sm text-foreground/80 px-1">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label={t("incomingOrders.selectAll")}
data-testid="select-all"
/>
<span>{t("incomingOrders.selectAll")}</span>
</label>
)}
{mine.length > 0 && (
<section>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 px-1">
@@ -410,6 +577,9 @@ export default function OrdersIncomingPage() {
order={o}
meId={meId}
onActionDone={refetch}
selectionMode={selectionMode}
selected={validSelected.has(o.id)}
onToggleSelected={toggleOne}
/>
))}
</div>
@@ -427,6 +597,9 @@ export default function OrdersIncomingPage() {
order={o}
meId={meId}
onActionDone={refetch}
selectionMode={selectionMode}
selected={validSelected.has(o.id)}
onToggleSelected={toggleOne}
/>
))}
</div>
@@ -435,6 +608,63 @@ export default function OrdersIncomingPage() {
</div>
)}
</div>
{selectionMode && selectedCount > 0 && (
<div
className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur px-4 py-3 flex items-center gap-3 max-w-2xl mx-auto"
data-testid="bulk-action-bar"
>
<div className="flex-1 text-sm text-foreground">
{t("incomingOrders.selectedCount", { count: selectedCount })}
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmOpen(true)}
disabled={bulkDelete.isPending}
data-testid="bulk-delete"
>
{bulkDelete.isPending ? (
<Loader2 size={14} className="animate-spin me-1.5" />
) : (
<Trash2 size={14} className="me-1.5" />
)}
{t("incomingOrders.delete")}
</Button>
</div>
)}
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("incomingOrders.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("incomingOrders.deleteConfirmBody", { count: selectedCount })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkDelete.isPending}>
{t("common.cancel", { defaultValue: t("incomingOrders.clearSelection") })}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
performDelete();
}}
disabled={bulkDelete.isPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
data-testid="confirm-delete"
>
{bulkDelete.isPending && (
<Loader2 size={14} className="animate-spin me-1.5" />
)}
{t("incomingOrders.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -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;
+101 -3
View File
@@ -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<BulkDeleteServiceOrdersResponse> => {
return customFetch<BulkDeleteServiceOrdersResponse>(
getBulkDeleteServiceOrdersUrl(),
{
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(bulkDeleteServiceOrdersBody),
},
);
};
export const getBulkDeleteServiceOrdersMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>,
TError,
{ data: BodyType<BulkDeleteServiceOrdersBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>,
TError,
{ data: BodyType<BulkDeleteServiceOrdersBody> },
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<ReturnType<typeof bulkDeleteServiceOrders>>,
{ data: BodyType<BulkDeleteServiceOrdersBody> }
> = (props) => {
const { data } = props ?? {};
return bulkDeleteServiceOrders(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type BulkDeleteServiceOrdersMutationResult = NonNullable<
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>
>;
export type BulkDeleteServiceOrdersMutationBody =
BodyType<BulkDeleteServiceOrdersBody>;
export type BulkDeleteServiceOrdersMutationError = ErrorType<ErrorResponse>;
/**
* @summary Delete multiple service orders in one request
*/
export const useBulkDeleteServiceOrders = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>,
TError,
{ data: BodyType<BulkDeleteServiceOrdersBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>,
TError,
{ data: BodyType<BulkDeleteServiceOrdersBody> },
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
*/
+63 -3
View File
@@ -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:
+29 -3
View File
@@ -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
*/