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:
@@ -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> => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, asc, sql } from "drizzle-orm";
|
||||
import { eq, asc, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
servicesTable,
|
||||
@@ -23,7 +23,32 @@ router.get("/services", requireAuth, async (_req, res): Promise<void> => {
|
||||
.select()
|
||||
.from(servicesTable)
|
||||
.orderBy(asc(servicesTable.sortOrder), asc(servicesTable.nameEn));
|
||||
res.json(services);
|
||||
|
||||
if (services.length === 0) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute orderCount per service so the admin delete dialog can warn
|
||||
// before the first click. The lazy 409 fallback in DELETE /services/:id
|
||||
// remains as a safety net.
|
||||
const serviceIds = services.map((s) => s.id);
|
||||
const orderRows = await db
|
||||
.select({
|
||||
serviceId: serviceOrdersTable.serviceId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(serviceOrdersTable)
|
||||
.where(inArray(serviceOrdersTable.serviceId, serviceIds))
|
||||
.groupBy(serviceOrdersTable.serviceId);
|
||||
const orderMap = new Map(orderRows.map((r) => [r.serviceId, r.count]));
|
||||
|
||||
res.json(
|
||||
services.map((s) => ({
|
||||
...s,
|
||||
orderCount: orderMap.get(s.id) ?? 0,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
router.post("/services", requireAdmin, async (req, res): Promise<void> => {
|
||||
|
||||
@@ -65,10 +65,18 @@ export async function getGroupsForUser(userId: number): Promise<GroupSummary[]>
|
||||
return rows;
|
||||
}
|
||||
|
||||
type DependencyCounts = {
|
||||
noteCount: number;
|
||||
orderCount: number;
|
||||
conversationCount: number;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
function buildUserProfile(
|
||||
user: typeof usersTable.$inferSelect,
|
||||
roles: string[],
|
||||
groups: GroupSummary[],
|
||||
counts?: DependencyCounts,
|
||||
) {
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -84,6 +92,7 @@ function buildUserProfile(
|
||||
roles,
|
||||
groups,
|
||||
createdAt: user.createdAt,
|
||||
...(counts ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,9 +268,60 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
groupsByUser.set(g.userId, list);
|
||||
}
|
||||
|
||||
// Batch load dependency counts so the admin delete dialog can warn on the
|
||||
// first click. Mirrors the pattern used by GET /groups. The lazy 409
|
||||
// fallback in DELETE /users/:id remains as a safety net.
|
||||
const noteRows = await db
|
||||
.select({
|
||||
userId: notesTable.userId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(notesTable)
|
||||
.where(inArray(notesTable.userId, userIds))
|
||||
.groupBy(notesTable.userId);
|
||||
const orderRows = await db
|
||||
.select({
|
||||
userId: serviceOrdersTable.userId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(serviceOrdersTable)
|
||||
.where(inArray(serviceOrdersTable.userId, userIds))
|
||||
.groupBy(serviceOrdersTable.userId);
|
||||
const convRows = await db
|
||||
.select({
|
||||
userId: conversationsTable.createdBy,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(conversationsTable)
|
||||
.where(inArray(conversationsTable.createdBy, userIds))
|
||||
.groupBy(conversationsTable.createdBy);
|
||||
const msgRows = await db
|
||||
.select({
|
||||
userId: messagesTable.senderId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(messagesTable)
|
||||
.where(inArray(messagesTable.senderId, userIds))
|
||||
.groupBy(messagesTable.senderId);
|
||||
|
||||
const noteMap = new Map(noteRows.map((r) => [r.userId, r.count]));
|
||||
const orderMap = new Map(orderRows.map((r) => [r.userId, r.count]));
|
||||
const convMap = new Map(convRows.map((r) => [r.userId, r.count]));
|
||||
const msgMap = new Map(msgRows.map((r) => [r.userId, r.count]));
|
||||
|
||||
res.json(
|
||||
users.map((u) =>
|
||||
buildUserProfile(u, rolesByUser.get(u.id) ?? [], groupsByUser.get(u.id) ?? []),
|
||||
buildUserProfile(
|
||||
u,
|
||||
rolesByUser.get(u.id) ?? [],
|
||||
groupsByUser.get(u.id) ?? [],
|
||||
{
|
||||
noteCount: noteMap.get(u.id) ?? 0,
|
||||
orderCount: orderMap.get(u.id) ?? 0,
|
||||
conversationCount: convMap.get(u.id) ?? 0,
|
||||
messageCount: msgMap.get(u.id) ?? 0,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
let adminId;
|
||||
let adminCookie;
|
||||
let depUserId;
|
||||
let depAppId;
|
||||
let depServiceId;
|
||||
let depGroupId;
|
||||
let depPermissionId;
|
||||
let depConvId;
|
||||
|
||||
async function loginAndGetCookie(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
return setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const adminUsername = `dep_admin_${STAMP}`;
|
||||
const a = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Dep Admin', 'en', true) RETURNING id`,
|
||||
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
adminId = a.rows[0].id;
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||||
[adminId],
|
||||
);
|
||||
|
||||
const u = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Dep User', 'en', true) RETURNING id`,
|
||||
[`dep_user_${STAMP}`, `dep_user_${STAMP}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
depUserId = u.rows[0].id;
|
||||
|
||||
const app = await pool.query(
|
||||
`INSERT INTO apps (slug, name_ar, name_en, route, color, icon_name)
|
||||
VALUES ($1, $2, $3, '/dep', '#000', 'Box') RETURNING id`,
|
||||
[`dep-app-${STAMP}`, `DepApp_${STAMP}`, `DepApp_${STAMP}`],
|
||||
);
|
||||
depAppId = app.rows[0].id;
|
||||
|
||||
const svc = await pool.query(
|
||||
`INSERT INTO services (name_ar, name_en, description_ar, description_en)
|
||||
VALUES ($1, $2, 'desc', 'desc') RETURNING id`,
|
||||
[`DepSvc_${STAMP}`, `DepSvc_${STAMP}`],
|
||||
);
|
||||
depServiceId = svc.rows[0].id;
|
||||
|
||||
const grp = await pool.query(
|
||||
`INSERT INTO groups (name, description_en) VALUES ($1, 'dep grp') RETURNING id`,
|
||||
[`DepGrp_${STAMP}`],
|
||||
);
|
||||
depGroupId = grp.rows[0].id;
|
||||
|
||||
// Create a custom permission to attach as an app restriction.
|
||||
const perm = await pool.query(
|
||||
`INSERT INTO permissions (name, description_en) VALUES ($1, 'dep perm') RETURNING id`,
|
||||
[`dep_perm_${STAMP}`],
|
||||
);
|
||||
depPermissionId = perm.rows[0].id;
|
||||
|
||||
// Dependents on the user: a note, an order, a conversation, a message.
|
||||
await pool.query(
|
||||
`INSERT INTO notes (user_id, content) VALUES ($1, 'note for dep test')`,
|
||||
[depUserId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO service_orders (service_id, user_id, status, notes)
|
||||
VALUES ($1, $2, 'pending', 'dep order')`,
|
||||
[depServiceId, depUserId],
|
||||
);
|
||||
const conv = await pool.query(
|
||||
`INSERT INTO conversations (is_group, name_en, created_by)
|
||||
VALUES (true, 'dep conv', $1) RETURNING id`,
|
||||
[depUserId],
|
||||
);
|
||||
depConvId = conv.rows[0].id;
|
||||
await pool.query(
|
||||
`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`,
|
||||
[depConvId, depUserId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
|
||||
[depConvId, depUserId],
|
||||
);
|
||||
|
||||
// App dependents: group link, permission restriction, an open record.
|
||||
await pool.query(
|
||||
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
|
||||
[depGroupId, depAppId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
|
||||
[depAppId, depPermissionId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO app_opens (app_id, user_id) VALUES ($1, $2)`,
|
||||
[depAppId, depUserId],
|
||||
);
|
||||
|
||||
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [depUserId]);
|
||||
await pool.query(`DELETE FROM conversation_participants WHERE user_id = $1`, [depUserId]);
|
||||
await pool.query(`DELETE FROM conversations WHERE id = $1`, [depConvId]);
|
||||
await pool.query(`DELETE FROM service_orders WHERE user_id = $1`, [depUserId]);
|
||||
await pool.query(`DELETE FROM notes WHERE user_id = $1`, [depUserId]);
|
||||
await pool.query(`DELETE FROM app_opens WHERE app_id = $1`, [depAppId]);
|
||||
await pool.query(`DELETE FROM app_permissions WHERE app_id = $1`, [depAppId]);
|
||||
await pool.query(`DELETE FROM group_apps WHERE app_id = $1`, [depAppId]);
|
||||
await pool.query(`DELETE FROM permissions WHERE id = $1`, [depPermissionId]);
|
||||
await pool.query(`DELETE FROM apps WHERE id = $1`, [depAppId]);
|
||||
await pool.query(`DELETE FROM services WHERE id = $1`, [depServiceId]);
|
||||
await pool.query(`DELETE FROM groups WHERE id = $1`, [depGroupId]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [[adminId, depUserId]]);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [[adminId, depUserId]]);
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("GET /api/admin/apps exposes groupCount/restrictionCount/openCount", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/admin/apps`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const apps = await res.json();
|
||||
const target = apps.find((a) => a.id === depAppId);
|
||||
assert.ok(target, "expected dep app in admin apps list");
|
||||
assert.equal(target.groupCount, 1, "groupCount should be 1");
|
||||
assert.equal(target.restrictionCount, 1, "restrictionCount should be 1");
|
||||
assert.equal(target.openCount, 1, "openCount should be 1");
|
||||
});
|
||||
|
||||
test("GET /api/services exposes orderCount", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/services`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const services = await res.json();
|
||||
const target = services.find((s) => s.id === depServiceId);
|
||||
assert.ok(target, "expected dep service in services list");
|
||||
assert.equal(target.orderCount, 1, "orderCount should be 1");
|
||||
});
|
||||
|
||||
test("GET /api/users exposes noteCount/orderCount/conversationCount/messageCount", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/users`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const users = await res.json();
|
||||
const target = users.find((u) => u.id === depUserId);
|
||||
assert.ok(target, "expected dep user in users list");
|
||||
assert.equal(target.noteCount, 1, "noteCount should be 1");
|
||||
assert.equal(target.orderCount, 1, "orderCount should be 1");
|
||||
assert.equal(target.conversationCount, 1, "conversationCount should be 1");
|
||||
assert.equal(target.messageCount, 1, "messageCount should be 1");
|
||||
});
|
||||
|
||||
test("Apps list returns zero counts for entities without dependents", async () => {
|
||||
const cleanApp = await pool.query(
|
||||
`INSERT INTO apps (slug, name_ar, name_en, route, color, icon_name)
|
||||
VALUES ($1, $2, $3, '/clean', '#000', 'Box') RETURNING id`,
|
||||
[`clean-app-${STAMP}`, `CleanApp_${STAMP}`, `CleanApp_${STAMP}`],
|
||||
);
|
||||
const cleanAppId = cleanApp.rows[0].id;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/apps`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const apps = await res.json();
|
||||
const target = apps.find((a) => a.id === cleanAppId);
|
||||
assert.ok(target, "expected clean app in admin apps list");
|
||||
assert.equal(target.groupCount, 0);
|
||||
assert.equal(target.restrictionCount, 0);
|
||||
assert.equal(target.openCount, 0);
|
||||
} finally {
|
||||
await pool.query(`DELETE FROM apps WHERE id = $1`, [cleanAppId]);
|
||||
}
|
||||
});
|
||||
|
||||
test("Services list returns zero orderCount for services without orders", async () => {
|
||||
const cleanSvc = await pool.query(
|
||||
`INSERT INTO services (name_ar, name_en, description_ar, description_en)
|
||||
VALUES ($1, $2, 'd', 'd') RETURNING id`,
|
||||
[`CleanSvc_${STAMP}`, `CleanSvc_${STAMP}`],
|
||||
);
|
||||
const cleanSvcId = cleanSvc.rows[0].id;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/services`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const services = await res.json();
|
||||
const target = services.find((s) => s.id === cleanSvcId);
|
||||
assert.ok(target, "expected clean service in services list");
|
||||
assert.equal(target.orderCount, 0);
|
||||
} finally {
|
||||
await pool.query(`DELETE FROM services WHERE id = $1`, [cleanSvcId]);
|
||||
}
|
||||
});
|
||||
|
||||
test("Users list returns zero counts for users without dependents", async () => {
|
||||
const cleanUser = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Clean User', 'en', true) RETURNING id`,
|
||||
[`clean_user_${STAMP}`, `clean_user_${STAMP}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const cleanUserId = cleanUser.rows[0].id;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/users`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const users = await res.json();
|
||||
const target = users.find((u) => u.id === cleanUserId);
|
||||
assert.ok(target, "expected clean user in users list");
|
||||
assert.equal(target.noteCount, 0);
|
||||
assert.equal(target.orderCount, 0);
|
||||
assert.equal(target.conversationCount, 0);
|
||||
assert.equal(target.messageCount, 0);
|
||||
} finally {
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [cleanUserId]);
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
@@ -840,7 +840,24 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAppDeleteConflict(null);
|
||||
// Pre-populate conflict from list-row counts so the
|
||||
// warning dialog shows on the FIRST click. Falls back
|
||||
// to the lazy 409 round-trip if counts are missing.
|
||||
const groupCount = app.groupCount ?? 0;
|
||||
const restrictionCount = app.restrictionCount ?? 0;
|
||||
const openCount = app.openCount ?? 0;
|
||||
const hasDeps =
|
||||
groupCount > 0 || restrictionCount > 0 || openCount > 0;
|
||||
setAppDeleteConflict(
|
||||
hasDeps
|
||||
? {
|
||||
error: "App has dependent records",
|
||||
groupCount,
|
||||
restrictionCount,
|
||||
openCount,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setAppDeleteTarget(app);
|
||||
}}
|
||||
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
@@ -899,7 +916,18 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setServiceDeleteConflict(null);
|
||||
// Pre-populate conflict from list-row orderCount so
|
||||
// the warning dialog shows on the FIRST click. Falls
|
||||
// back to the lazy 409 round-trip if count is missing.
|
||||
const orderCount = service.orderCount ?? 0;
|
||||
setServiceDeleteConflict(
|
||||
orderCount > 0
|
||||
? {
|
||||
error: "Service has dependent records",
|
||||
orderCount,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setServiceDeleteTarget(service);
|
||||
}}
|
||||
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
@@ -2412,7 +2440,29 @@ function UsersPanel({
|
||||
{u.id !== currentUserId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserDeleteConflict(null);
|
||||
// Pre-populate conflict from list-row counts so the
|
||||
// warning dialog shows on the FIRST click. Falls back
|
||||
// to the lazy 409 round-trip if counts are missing.
|
||||
const noteCount = u.noteCount ?? 0;
|
||||
const orderCount = u.orderCount ?? 0;
|
||||
const conversationCount = u.conversationCount ?? 0;
|
||||
const messageCount = u.messageCount ?? 0;
|
||||
const hasDeps =
|
||||
noteCount > 0 ||
|
||||
orderCount > 0 ||
|
||||
conversationCount > 0 ||
|
||||
messageCount > 0;
|
||||
setUserDeleteConflict(
|
||||
hasDeps
|
||||
? {
|
||||
error: "User has dependent records",
|
||||
noteCount,
|
||||
orderCount,
|
||||
conversationCount,
|
||||
messageCount,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setUserDeleteTarget(u);
|
||||
}}
|
||||
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
|
||||
@@ -151,6 +151,23 @@ export interface UserProfile {
|
||||
roles: string[];
|
||||
groups: GroupSummary[];
|
||||
createdAt: string;
|
||||
/** Dependent record counts. Populated only by the admin list endpoint
|
||||
(GET /users) so the delete dialog can warn before the first click.
|
||||
Omitted from single-user responses (GET /users/:id, PATCH, etc).
|
||||
*/
|
||||
noteCount?: number;
|
||||
/** Number of service orders owned by this user. Populated only by the
|
||||
admin list endpoint (GET /users).
|
||||
*/
|
||||
orderCount?: number;
|
||||
/** Number of conversations created by this user. Populated only by the
|
||||
admin list endpoint (GET /users).
|
||||
*/
|
||||
conversationCount?: number;
|
||||
/** Number of messages authored by this user. Populated only by the
|
||||
admin list endpoint (GET /users).
|
||||
*/
|
||||
messageCount?: number;
|
||||
}
|
||||
|
||||
export interface UpdateUserBody {
|
||||
@@ -314,6 +331,20 @@ export interface App {
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Number of groups granting access to this app. Populated only by the
|
||||
admin list endpoint (GET /admin/apps) so the delete dialog can warn
|
||||
before the first click. Omitted from non-admin and single-app
|
||||
responses.
|
||||
*/
|
||||
groupCount?: number;
|
||||
/** Number of legacy permission restrictions for this app. Populated
|
||||
only by the admin list endpoint (GET /admin/apps).
|
||||
*/
|
||||
restrictionCount?: number;
|
||||
/** Number of recorded app-open events for this app. Populated only by
|
||||
the admin list endpoint (GET /admin/apps).
|
||||
*/
|
||||
openCount?: number;
|
||||
}
|
||||
|
||||
export interface CreateAppBody {
|
||||
@@ -364,6 +395,12 @@ export interface Service {
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Number of service_orders rows referencing this service. Populated
|
||||
only by the list endpoint (GET /services) so the admin delete
|
||||
dialog can warn before the first click. Omitted from single-service
|
||||
responses.
|
||||
*/
|
||||
orderCount?: number;
|
||||
}
|
||||
|
||||
export interface CreateServiceBody {
|
||||
|
||||
@@ -2156,6 +2156,27 @@ components:
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
noteCount:
|
||||
type: integer
|
||||
description: |
|
||||
Dependent record counts. Populated only by the admin list endpoint
|
||||
(GET /users) so the delete dialog can warn before the first click.
|
||||
Omitted from single-user responses (GET /users/:id, PATCH, etc).
|
||||
orderCount:
|
||||
type: integer
|
||||
description: |
|
||||
Number of service orders owned by this user. Populated only by the
|
||||
admin list endpoint (GET /users).
|
||||
conversationCount:
|
||||
type: integer
|
||||
description: |
|
||||
Number of conversations created by this user. Populated only by the
|
||||
admin list endpoint (GET /users).
|
||||
messageCount:
|
||||
type: integer
|
||||
description: |
|
||||
Number of messages authored by this user. Populated only by the
|
||||
admin list endpoint (GET /users).
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
@@ -2507,6 +2528,23 @@ components:
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
groupCount:
|
||||
type: integer
|
||||
description: |
|
||||
Number of groups granting access to this app. Populated only by the
|
||||
admin list endpoint (GET /admin/apps) so the delete dialog can warn
|
||||
before the first click. Omitted from non-admin and single-app
|
||||
responses.
|
||||
restrictionCount:
|
||||
type: integer
|
||||
description: |
|
||||
Number of legacy permission restrictions for this app. Populated
|
||||
only by the admin list endpoint (GET /admin/apps).
|
||||
openCount:
|
||||
type: integer
|
||||
description: |
|
||||
Number of recorded app-open events for this app. Populated only by
|
||||
the admin list endpoint (GET /admin/apps).
|
||||
required:
|
||||
- id
|
||||
- slug
|
||||
@@ -2605,6 +2643,13 @@ components:
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
orderCount:
|
||||
type: integer
|
||||
description: |
|
||||
Number of service_orders rows referencing this service. Populated
|
||||
only by the list endpoint (GET /services) so the admin delete
|
||||
dialog can warn before the first click. Omitted from single-service
|
||||
responses.
|
||||
required:
|
||||
- id
|
||||
- nameAr
|
||||
|
||||
@@ -295,6 +295,24 @@ export const ListAppsResponseItem = zod.object({
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
groupCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of groups granting access to this app. Populated only by the\nadmin list endpoint (GET \/admin\/apps) so the delete dialog can warn\nbefore the first click. Omitted from non-admin and single-app\nresponses.\n",
|
||||
),
|
||||
restrictionCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of legacy permission restrictions for this app. Populated\nonly by the admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
openCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of recorded app-open events for this app. Populated only by\nthe admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
});
|
||||
export const ListAppsResponse = zod.array(ListAppsResponseItem);
|
||||
|
||||
@@ -337,6 +355,24 @@ export const GetAppResponse = zod.object({
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
groupCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of groups granting access to this app. Populated only by the\nadmin list endpoint (GET \/admin\/apps) so the delete dialog can warn\nbefore the first click. Omitted from non-admin and single-app\nresponses.\n",
|
||||
),
|
||||
restrictionCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of legacy permission restrictions for this app. Populated\nonly by the admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
openCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of recorded app-open events for this app. Populated only by\nthe admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -373,6 +409,24 @@ export const UpdateAppResponse = zod.object({
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
groupCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of groups granting access to this app. Populated only by the\nadmin list endpoint (GET \/admin\/apps) so the delete dialog can warn\nbefore the first click. Omitted from non-admin and single-app\nresponses.\n",
|
||||
),
|
||||
restrictionCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of legacy permission restrictions for this app. Populated\nonly by the admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
openCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of recorded app-open events for this app. Populated only by\nthe admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -424,6 +478,24 @@ export const UpdateMyAppOrderResponseItem = zod.object({
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
groupCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of groups granting access to this app. Populated only by the\nadmin list endpoint (GET \/admin\/apps) so the delete dialog can warn\nbefore the first click. Omitted from non-admin and single-app\nresponses.\n",
|
||||
),
|
||||
restrictionCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of legacy permission restrictions for this app. Populated\nonly by the admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
openCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of recorded app-open events for this app. Populated only by\nthe admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
});
|
||||
export const UpdateMyAppOrderResponse = zod.array(UpdateMyAppOrderResponseItem);
|
||||
|
||||
@@ -521,6 +593,12 @@ export const ListServicesResponseItem = zod.object({
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
orderCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of service_orders rows referencing this service. Populated\nonly by the list endpoint (GET \/services) so the admin delete\ndialog can warn before the first click. Omitted from single-service\nresponses.\n",
|
||||
),
|
||||
});
|
||||
export const ListServicesResponse = zod.array(ListServicesResponseItem);
|
||||
|
||||
@@ -559,6 +637,12 @@ export const GetServiceResponse = zod.object({
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
orderCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of service_orders rows referencing this service. Populated\nonly by the list endpoint (GET \/services) so the admin delete\ndialog can warn before the first click. Omitted from single-service\nresponses.\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -593,6 +677,12 @@ export const UpdateServiceResponse = zod.object({
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
orderCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of service_orders rows referencing this service. Populated\nonly by the list endpoint (GET \/services) so the admin delete\ndialog can warn before the first click. Omitted from single-service\nresponses.\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1405,6 +1495,30 @@ export const ListUsersResponseItem = zod.object({
|
||||
}),
|
||||
),
|
||||
createdAt: zod.coerce.date(),
|
||||
noteCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Dependent record counts. Populated only by the admin list endpoint\n(GET \/users) so the delete dialog can warn before the first click.\nOmitted from single-user responses (GET \/users\/:id, PATCH, etc).\n",
|
||||
),
|
||||
orderCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
conversationCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
messageCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
});
|
||||
export const ListUsersResponse = zod.array(ListUsersResponseItem);
|
||||
|
||||
@@ -1465,6 +1579,30 @@ export const GetUserResponse = zod.object({
|
||||
}),
|
||||
),
|
||||
createdAt: zod.coerce.date(),
|
||||
noteCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Dependent record counts. Populated only by the admin list endpoint\n(GET \/users) so the delete dialog can warn before the first click.\nOmitted from single-user responses (GET \/users\/:id, PATCH, etc).\n",
|
||||
),
|
||||
orderCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
conversationCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
messageCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1513,6 +1651,30 @@ export const UpdateUserResponse = zod.object({
|
||||
}),
|
||||
),
|
||||
createdAt: zod.coerce.date(),
|
||||
noteCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Dependent record counts. Populated only by the admin list endpoint\n(GET \/users) so the delete dialog can warn before the first click.\nOmitted from single-user responses (GET \/users\/:id, PATCH, etc).\n",
|
||||
),
|
||||
orderCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
conversationCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
messageCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1583,6 +1745,30 @@ export const AddUserRoleResponse = zod.object({
|
||||
}),
|
||||
),
|
||||
createdAt: zod.coerce.date(),
|
||||
noteCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Dependent record counts. Populated only by the admin list endpoint\n(GET \/users) so the delete dialog can warn before the first click.\nOmitted from single-user responses (GET \/users\/:id, PATCH, etc).\n",
|
||||
),
|
||||
orderCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
conversationCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
messageCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1621,6 +1807,30 @@ export const RemoveUserRoleResponse = zod.object({
|
||||
}),
|
||||
),
|
||||
createdAt: zod.coerce.date(),
|
||||
noteCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Dependent record counts. Populated only by the admin list endpoint\n(GET \/users) so the delete dialog can warn before the first click.\nOmitted from single-user responses (GET \/users\/:id, PATCH, etc).\n",
|
||||
),
|
||||
orderCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of service orders owned by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
conversationCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of conversations created by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
messageCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of messages authored by this user. Populated only by the\nadmin list endpoint (GET \/users).\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
- **خدماتي (My Services)**: service card grid with availability status
|
||||
- **Internal Chat**: real-time messages via Socket.IO, conversation list
|
||||
- **Notifications**: unread tracking, mark-all-read
|
||||
- **Admin Panel**: CRUD for apps, services, users (admin role required)
|
||||
- **Admin Panel**: CRUD for apps, services, users (admin role required). Delete dialogs show a dependency warning on the FIRST click using count fields (`groupCount`/`restrictionCount`/`openCount` on apps, `orderCount` on services, `noteCount`/`orderCount`/`conversationCount`/`messageCount` on users) returned by the list endpoints (`GET /api/admin/apps`, `GET /api/services`, `GET /api/users`); the lazy 409 conflict response from `DELETE /api/{apps,services,users}/:id` (with `?force=true` to override) remains as a safety net.
|
||||
- **Session-based Auth**: RBAC with roles (admin/user)
|
||||
- **Executive Meetings (Phase 2)**: bilingual full-stack module under `/executive-meetings` with 9 sections — Schedule (centered cells, attendees widest column, RTL-locked column order # / الاجتماع / الحضور / الوقت), Manage Meetings (CRUD with attendee replace, transactional), Change Requests (submit / withdraw, supports `meetingId=null` for create-suggestions), Approvals (approve/reject with review notes), Tasks (CRUD with assignee status updates — assignees and mutators can change status, only mutators can delete), Notifications (per-user feed), Audit Log (full action chain, admin role required), PDF (window.print export), Font Settings (per-user + global scope, family/size/weight/alignment with live preview). RBAC enforced via 5 role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT) and a `makeRequireRoles` middleware factory; `/api/executive-meetings/me` returns `{userId, roles, canRead, canMutate, canApprove, canSubmitRequest, canViewAudit}`. Every mutation (meeting/request/task/font CRUD) wraps the DB write **and** the audit-log insert in the same `db.transaction(...)` so audit entries cannot drift from state. Routes use `router.param("id")` with `next("route")` to handle path-to-regexp 8 (no inline `:id(\\d+)` support).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user