fix: tell receivers when an order is no longer available + fix disabled apps disappearing from admin

## Task #80 — Tell receivers when an order is no longer available to claim

### Problem
When a receiver tried to act on an order that had already been cancelled, completed,
or claimed by someone else, the UI showed a generic "Could not complete the action"
toast. The stale card also stayed in the list, requiring a manual refresh.

### Changes

**artifacts/api-server/src/routes/service-orders.ts**
- PATCH /orders/:id/confirm-receipt: after a failed atomic claim, query the current
  order status; return 409 `order_unavailable` if it's cancelled/completed (vs the
  existing 409 `already_claimed` for when it was grabbed by someone else first)
- PATCH /orders/:id/status (preparing/completed branch): terminal-state check runs
  BEFORE permission gating — returns 409 `order_unavailable` when order is already
  cancelled/completed instead of ever hitting 403 Forbidden
- PATCH /orders/:id/status (cancel branch): same — terminal-state check moved to
  the top of the branch so non-admin receivers trying to cancel an already-terminal
  order get 409 `order_unavailable` (not 403 Forbidden which would never let the UI
  remove the stale card)

**artifacts/tx-os/src/pages/orders-incoming.tsx**
- handleConfirmReceipt onError: distinguish 409 `order_unavailable` from
  `already_claimed`, show specific toast, remove stale card via invalidate()
- handleSetStatus onError: same pattern — specific toast + invalidate() on
  `order_unavailable`
- handleCancel onError: same pattern

**artifacts/tx-os/src/locales/en.json + ar.json**
- Added `incomingOrders.orderUnavailable` ("This order is no longer available" /
  "هذا الطلب لم يعد متاحاً")

## Bonus fix — Disabled app disappears from admin panel (user-reported)

### Problem
When an admin disabled an app via the toggle, the app disappeared from the admin
panel too (isActive filter applied to everyone), making it impossible to re-enable
without direct DB access.

### Changes

**artifacts/api-server/src/routes/apps.ts**
- getVisibleAppsForUser: admins now receive ALL apps including inactive ones via
  conditional $dynamic() Drizzle query; non-admins still only see active apps

**artifacts/tx-os/src/pages/home.tsx**
- Filter orderedApps to only active apps before rendering so inactive apps don't
  appear on the home screen even for admins

**artifacts/tx-os/src/pages/admin.tsx**
- Inactive app cards show reduced opacity + a "Disabled/معطّل" badge

**artifacts/tx-os/src/locales/en.json + ar.json**
- Added `admin.appDisabled` ("Disabled" / "معطّل")
This commit is contained in:
Riyadh
2026-04-27 09:06:23 +00:00
parent 14ce113e70
commit 57292caa07
7 changed files with 92 additions and 31 deletions
+12 -6
View File
@@ -37,7 +37,9 @@ async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$
const isAdmin = adminRoleRows.length > 0;
// Pull the user's preferred order, then order by COALESCE(user_order, app.sort_order, name).
const orderedRows = await db
// Admins receive ALL apps (including inactive ones) so they can re-enable them.
// Non-admins only receive active apps.
const baseQuery = db
.select({
app: appsTable,
userSort: userAppOrdersTable.sortOrder,
@@ -47,11 +49,15 @@ async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$
userAppOrdersTable,
sql`${userAppOrdersTable.appId} = ${appsTable.id} and ${userAppOrdersTable.userId} = ${userId}`,
)
.where(eq(appsTable.isActive, true))
.orderBy(
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
asc(appsTable.nameEn),
);
.$dynamic();
const orderedRows = await (isAdmin
? baseQuery
: baseQuery.where(eq(appsTable.isActive, true))
).orderBy(
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
asc(appsTable.nameEn),
);
const apps = orderedRows.map((r) => r.app);
@@ -248,7 +248,18 @@ router.patch(
.returning();
if (!updated) {
res.status(409).json({ error: "already_claimed" });
// Distinguish: was it claimed by someone else, or is it no longer claimable
// (cancelled / completed / already in a terminal state)?
const current = await db
.select({ status: serviceOrdersTable.status })
.from(serviceOrdersTable)
.where(eq(serviceOrdersTable.id, orderId));
const currentStatus = current[0]?.status;
if (!currentStatus || currentStatus === "cancelled" || currentStatus === "completed") {
res.status(409).json({ error: "order_unavailable" });
} else {
res.status(409).json({ error: "already_claimed" });
}
return;
}
@@ -345,6 +356,13 @@ router.patch(
return;
}
} else if (next === "preparing" || next === "completed") {
// If the order is already terminal (cancelled or completed by someone
// else), surface a specific error so the receiver UI can show the right
// message and remove the stale card.
if (existing.status === "cancelled" || existing.status === "completed") {
res.status(409).json({ error: "order_unavailable" });
return;
}
// Only the assigned receiver or admin
const isAssignee = existing.assignedTo === userId;
if (!isAssignee && !admin) {
@@ -365,6 +383,12 @@ router.patch(
res.status(400).json({ error: "invalid_transition" });
return;
} else if (next === "cancelled") {
// Check terminal state first so the receiver sees the right message
// regardless of permission gating order.
if (existing.status === "cancelled" || existing.status === "completed") {
res.status(409).json({ error: "order_unavailable" });
return;
}
const isOwner = existing.userId === userId;
const isAssignee = existing.assignedTo === userId;
const cancellableByOwner =
@@ -379,11 +403,6 @@ router.patch(
res.status(403).json({ error: "Forbidden" });
return;
}
// Admin can cancel any status. Already-cancelled is a no-op invalid transition.
if (existing.status === "cancelled") {
res.status(400).json({ error: "invalid_transition" });
return;
}
} else {
res.status(400).json({ error: "invalid_transition" });
return;