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
@@ -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");