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> => {
+27 -2
View File
@@ -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> => {
+61 -1
View File
@@ -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

+53 -3
View File
@@ -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 {
+45
View File
@@ -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
+210
View File
@@ -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",
),
});
/**
+1 -1
View File
@@ -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).