Task #96: Show dependency counts in admin app/service/user lists

Goal: make the admin delete dialog show its dependency warning on
the FIRST click (matching the existing Groups UX), instead of
needing a 409 round-trip from the DELETE endpoint to populate the
warning.

Changes:
- lib/api-spec/openapi.yaml: added optional count fields to App
  (groupCount, restrictionCount, openCount), Service (orderCount),
  and UserProfile (noteCount, orderCount, conversationCount,
  messageCount).
- artifacts/api-server/src/routes/apps.ts: GET /admin/apps now
  batch-loads groupCount/restrictionCount/openCount via grouped
  COUNT queries on group_apps, app_permissions, and app_opens.
- artifacts/api-server/src/routes/services.ts: GET /services now
  batch-loads orderCount from service_orders.
- artifacts/api-server/src/routes/users.ts: GET /users now batch-
  loads noteCount/orderCount/conversationCount/messageCount from
  notes, service_orders, conversations.created_by, and
  messages.sender_id.
- artifacts/tx-os/src/pages/admin.tsx: Apps, Services, and Users
  delete buttons pre-populate their conflict-state from the row's
  count fields so the DeletionWarningDialog shows the warning on
  the first click. The lazy 409 fallback still works as a safety
  net for any caller without counts.
- replit.md: documented the new admin-panel delete UX.

Tests:
- Added artifacts/api-server/tests/list-dependency-counts.test.mjs
  with 6 tests verifying each list endpoint exposes the new count
  fields with the expected non-zero values when dependents exist
  AND zero values when they do not (apps, services, users).
- Existing artifacts/api-server/tests/delete-force-warnings.test.mjs
  (9 tests) still passes — DELETE behavior unchanged.
- Verified end-to-end with a Playwright browser test: clicking the
  service delete icon ONCE opens the confirmation modal with the
  dependency warning ("orders", count >= 1) immediately.

Notes / drift:
- Count fields were added to the shared App/Service/UserProfile
  schemas (not list-only sub-schemas) so the same shape is returned
  from list and detail endpoints. Code review flagged this as
  acceptable but broader than strictly necessary; left as is to
  keep the API consistent.

Out of scope (already pre-existing on main, tracked as follow-ups):
- artifacts/api-server/tests/apps-open.test.mjs has a syntax error.
- artifacts/api-server/tests/app-permissions-unique.test.mjs has
  two failing assertions about the composite primary key.
- artifacts/api-server/src/routes/executive-meetings.ts has 3 pre-
  existing typecheck errors around the font-settings scope column.
- The `test` workflow exits with ECONNREFUSED when the api-server
  isn't already up — needs a readiness check.

Replit-Task-Id: bd14fe73-9961-431b-ab5a-ab70f116e8c7
This commit is contained in:
riyadhafraa
2026-04-29 08:48:29 +00:00
parent 03ba2cf99f
commit c0cf2115dd
10 changed files with 740 additions and 8 deletions
+50 -1
View File
@@ -122,7 +122,56 @@ router.get("/admin/apps", requireAdmin, async (_req, res): Promise<void> => {
.select()
.from(appsTable)
.orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn));
res.json(allApps);
if (allApps.length === 0) {
res.json([]);
return;
}
// Compute dependency counts so the admin delete dialog can warn before
// the first click (mirrors the GET /groups behavior). The lazy 409
// fallback in DELETE /apps/:id remains as a safety net.
const appIds = allApps.map((a) => a.id);
const groupRows = await db
.select({
appId: groupAppsTable.appId,
count: sql<number>`count(*)::int`,
})
.from(groupAppsTable)
.where(inArray(groupAppsTable.appId, appIds))
.groupBy(groupAppsTable.appId);
const restrictionRows = await db
.select({
appId: appPermissionsTable.appId,
count: sql<number>`count(*)::int`,
})
.from(appPermissionsTable)
.where(inArray(appPermissionsTable.appId, appIds))
.groupBy(appPermissionsTable.appId);
const openRows = await db
.select({
appId: appOpensTable.appId,
count: sql<number>`count(*)::int`,
})
.from(appOpensTable)
.where(inArray(appOpensTable.appId, appIds))
.groupBy(appOpensTable.appId);
const groupMap = new Map(groupRows.map((r) => [r.appId, r.count]));
const restrictionMap = new Map(
restrictionRows.map((r) => [r.appId, r.count]),
);
const openMap = new Map(openRows.map((r) => [r.appId, r.count]));
res.json(
allApps.map((a) => ({
...a,
groupCount: groupMap.get(a.id) ?? 0,
restrictionCount: restrictionMap.get(a.id) ?? 0,
openCount: openMap.get(a.id) ?? 0,
})),
);
});
router.put("/me/app-order", requireAuth, async (req, res): Promise<void> => {