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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user