Task #183: clickable dependency counts on admin Apps/Services/Users
Turned the inline dependency-count badges on each admin row into
focusable, keyboard-accessible buttons that open a drill-in modal
listing the actual rows behind the count.
Backend (artifacts/api-server/src/routes):
- apps.ts: GET /admin/apps/:id/dependents/{groups,restrictions,opens}
- services.ts: GET /admin/services/:id/dependents/orders
- users.ts: GET /admin/users/:id/dependents/{notes,orders,conversations,messages}
All paginated (limit default 50, max 200; offset) returning
{items, totalCount, limit, offset, nextOffset}. Restrictions joins
the role names that include each required permission.
OpenAPI:
- Added 8 path operations + 16 schemas (Item + Page) and re-ran
`pnpm --filter @workspace/api-spec run codegen`.
Frontend (artifacts/tx-os/src/pages/admin.tsx):
- New DependencyDrillIn component reuses DrillInShell (Escape +
backdrop close, RTL/LTR safe) and the existing LoadMoreSection
pagination pattern from AppOpensDrillIn.
- Each count part in Apps, Services, and Users panels is now a
<button> with a unique data-testid (e.g. app-counts-groups-213,
user-counts-messages-5) and an aria-label that reads
"View {count}".
- AdminPage owns dependencyTarget state for Apps/Services counts;
UsersPanel owns its own (it already encapsulates user list).
Translations:
- Added admin.dependents.* (titles, subtitles, empty/error/load
labels, conversation/order/message helper strings, order status
enum) to en.json and ar.json.
Verification: - tx-os typecheck clean; api-server has only the pre-existing
executive-meetings.ts errors (untouched).
- e2e tested via runTest: login as admin, opened Apps panel,
drilled into groups + opens (Load more grew 50→100), drilled into
Tea orders, drilled into user 5 conversations and messages,
switched to Arabic/RTL and re-opened the Groups drill-in to
confirm Arabic rendering with no raw i18n keys.
Replit-Task-Id: fe96a05b-325f-4e1f-901b-3a2235fb24b5
This commit is contained in:
@@ -418,6 +418,182 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
res.json(app);
|
||||
});
|
||||
|
||||
// ===== Dependent-item drill-ins =====
|
||||
//
|
||||
// Each "<count> X" badge on the admin Apps row is backed by one of these
|
||||
// endpoints so admins can click through to see *which* groups grant the
|
||||
// app, *which* permissions restrict it, and *which* opens were recorded.
|
||||
// The list endpoint (GET /admin/apps) only returns counts; this returns
|
||||
// the actual rows. All paginated to match the existing
|
||||
// /stats/admin/app-opens/by-app/:appId shape (limit/offset/totalCount/
|
||||
// nextOffset) so the frontend can reuse its LoadMoreSection.
|
||||
const DEPENDENTS_DEFAULT_LIMIT = 50;
|
||||
const DEPENDENTS_MAX_LIMIT = 200;
|
||||
|
||||
function parseDependentsPaging(req: { query: Record<string, unknown> }): {
|
||||
limit: number;
|
||||
offset: number;
|
||||
} {
|
||||
const limitRaw = Number(req.query.limit);
|
||||
const offsetRaw = Number(req.query.offset);
|
||||
const limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), DEPENDENTS_MAX_LIMIT)
|
||||
: DEPENDENTS_DEFAULT_LIMIT;
|
||||
const offset =
|
||||
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
async function ensureAppExists(id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: appsTable.id })
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.id, id));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/admin/apps/:id/dependents/groups",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureAppExists(id))) {
|
||||
res.status(404).json({ error: "App not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(groupAppsTable)
|
||||
.where(eq(groupAppsTable.appId, id));
|
||||
const items = await db
|
||||
.select({
|
||||
id: groupsTable.id,
|
||||
name: groupsTable.name,
|
||||
descriptionAr: groupsTable.descriptionAr,
|
||||
descriptionEn: groupsTable.descriptionEn,
|
||||
})
|
||||
.from(groupAppsTable)
|
||||
.innerJoin(groupsTable, eq(groupAppsTable.groupId, groupsTable.id))
|
||||
.where(eq(groupAppsTable.appId, id))
|
||||
.orderBy(asc(groupsTable.name))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin/apps/:id/dependents/restrictions",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureAppExists(id))) {
|
||||
res.status(404).json({ error: "App not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(appPermissionsTable)
|
||||
.where(eq(appPermissionsTable.appId, id));
|
||||
const permRows = await db
|
||||
.select({
|
||||
id: permissionsTable.id,
|
||||
name: permissionsTable.name,
|
||||
descriptionAr: permissionsTable.descriptionAr,
|
||||
descriptionEn: permissionsTable.descriptionEn,
|
||||
})
|
||||
.from(appPermissionsTable)
|
||||
.innerJoin(
|
||||
permissionsTable,
|
||||
eq(appPermissionsTable.permissionId, permissionsTable.id),
|
||||
)
|
||||
.where(eq(appPermissionsTable.appId, id))
|
||||
.orderBy(asc(permissionsTable.name))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
// Pull role names that grant each restriction so the admin sees who
|
||||
// currently passes the gate (not just an opaque permission name).
|
||||
const permIds = permRows.map((p) => p.id);
|
||||
const roleRows = permIds.length
|
||||
? await db
|
||||
.select({
|
||||
permissionId: rolePermissionsTable.permissionId,
|
||||
roleName: rolesTable.name,
|
||||
})
|
||||
.from(rolePermissionsTable)
|
||||
.innerJoin(rolesTable, eq(rolePermissionsTable.roleId, rolesTable.id))
|
||||
.where(inArray(rolePermissionsTable.permissionId, permIds))
|
||||
.orderBy(asc(rolesTable.name))
|
||||
: [];
|
||||
const rolesByPerm = new Map<number, string[]>();
|
||||
for (const r of roleRows) {
|
||||
const list = rolesByPerm.get(r.permissionId) ?? [];
|
||||
list.push(r.roleName);
|
||||
rolesByPerm.set(r.permissionId, list);
|
||||
}
|
||||
const items = permRows.map((p) => ({
|
||||
...p,
|
||||
roles: rolesByPerm.get(p.id) ?? [],
|
||||
}));
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin/apps/:id/dependents/opens",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureAppExists(id))) {
|
||||
res.status(404).json({ error: "App not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(appOpensTable)
|
||||
.where(eq(appOpensTable.appId, id));
|
||||
// Unlike /stats/admin/app-opens/by-app/:appId, this drill is not time-
|
||||
// filtered: the row count covers the entire history of opens, so the
|
||||
// popover must reflect the same population to avoid misleading admins.
|
||||
const items = await db
|
||||
.select({
|
||||
id: appOpensTable.id,
|
||||
createdAt: appOpensTable.createdAt,
|
||||
userId: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
})
|
||||
.from(appOpensTable)
|
||||
.innerJoin(usersTable, eq(appOpensTable.userId, usersTable.id))
|
||||
.where(eq(appOpensTable.appId, id))
|
||||
.orderBy(sql`${appOpensTable.createdAt} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/apps/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
|
||||
const params = GetAppParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
serviceCategoriesTable,
|
||||
serviceOrdersTable,
|
||||
auditLogsTable,
|
||||
usersTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||
import {
|
||||
@@ -117,6 +118,64 @@ router.patch("/services/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
res.json(service);
|
||||
});
|
||||
|
||||
// Drill-in for the "<count> orders" badge on the admin Services row.
|
||||
// Mirrors the pagination shape used by /admin/apps/:id/dependents/* so
|
||||
// the frontend can reuse a single drill-in component.
|
||||
const SERVICE_DEPENDENTS_DEFAULT_LIMIT = 50;
|
||||
const SERVICE_DEPENDENTS_MAX_LIMIT = 200;
|
||||
|
||||
router.get(
|
||||
"/admin/services/:id/dependents/orders",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
const [service] = await db
|
||||
.select({ id: servicesTable.id })
|
||||
.from(servicesTable)
|
||||
.where(eq(servicesTable.id, id));
|
||||
if (!service) {
|
||||
res.status(404).json({ error: "Service not found" });
|
||||
return;
|
||||
}
|
||||
const limitRaw = Number(req.query.limit);
|
||||
const offsetRaw = Number(req.query.offset);
|
||||
const limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), SERVICE_DEPENDENTS_MAX_LIMIT)
|
||||
: SERVICE_DEPENDENTS_DEFAULT_LIMIT;
|
||||
const offset =
|
||||
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
||||
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(serviceOrdersTable)
|
||||
.where(eq(serviceOrdersTable.serviceId, id));
|
||||
const items = await db
|
||||
.select({
|
||||
id: serviceOrdersTable.id,
|
||||
status: serviceOrdersTable.status,
|
||||
notes: serviceOrdersTable.notes,
|
||||
createdAt: serviceOrdersTable.createdAt,
|
||||
userId: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
})
|
||||
.from(serviceOrdersTable)
|
||||
.innerJoin(usersTable, eq(serviceOrdersTable.userId, usersTable.id))
|
||||
.where(eq(serviceOrdersTable.serviceId, id))
|
||||
.orderBy(sql`${serviceOrdersTable.createdAt} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/services/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const params = DeleteServiceParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
userGroupsTable,
|
||||
notesTable,
|
||||
serviceOrdersTable,
|
||||
servicesTable,
|
||||
conversationsTable,
|
||||
messagesTable,
|
||||
auditLogsTable,
|
||||
@@ -369,6 +370,198 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// ===== Dependent-item drill-ins =====
|
||||
//
|
||||
// Each "<count> X" badge on the admin Users row is backed by one of these
|
||||
// endpoints so admins can click through to see *which* notes, orders,
|
||||
// conversations, and messages the user has produced. Pagination shape
|
||||
// mirrors /admin/apps/:id/dependents/* so a single drill-in component on
|
||||
// the frontend can consume them all.
|
||||
const USER_DEPENDENTS_DEFAULT_LIMIT = 50;
|
||||
const USER_DEPENDENTS_MAX_LIMIT = 200;
|
||||
|
||||
function parseUserDependentsPaging(req: { query: Record<string, unknown> }): {
|
||||
limit: number;
|
||||
offset: number;
|
||||
} {
|
||||
const limitRaw = Number(req.query.limit);
|
||||
const offsetRaw = Number(req.query.offset);
|
||||
const limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), USER_DEPENDENTS_MAX_LIMIT)
|
||||
: USER_DEPENDENTS_DEFAULT_LIMIT;
|
||||
const offset =
|
||||
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
async function ensureUserExists(id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/admin/users/:id/dependents/notes",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureUserExists(id))) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseUserDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(notesTable)
|
||||
.where(eq(notesTable.userId, id));
|
||||
const items = await db
|
||||
.select({
|
||||
id: notesTable.id,
|
||||
title: notesTable.title,
|
||||
isPinned: notesTable.isPinned,
|
||||
isArchived: notesTable.isArchived,
|
||||
updatedAt: notesTable.updatedAt,
|
||||
})
|
||||
.from(notesTable)
|
||||
.where(eq(notesTable.userId, id))
|
||||
.orderBy(sql`${notesTable.updatedAt} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin/users/:id/dependents/orders",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureUserExists(id))) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseUserDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(serviceOrdersTable)
|
||||
.where(eq(serviceOrdersTable.userId, id));
|
||||
// Join service so the popover can identify what was ordered without
|
||||
// a follow-up fetch — the count badge says "12 orders" but admins
|
||||
// need the service names to investigate before reassigning.
|
||||
const items = await db
|
||||
.select({
|
||||
id: serviceOrdersTable.id,
|
||||
status: serviceOrdersTable.status,
|
||||
notes: serviceOrdersTable.notes,
|
||||
createdAt: serviceOrdersTable.createdAt,
|
||||
serviceId: servicesTable.id,
|
||||
serviceNameAr: servicesTable.nameAr,
|
||||
serviceNameEn: servicesTable.nameEn,
|
||||
})
|
||||
.from(serviceOrdersTable)
|
||||
.innerJoin(servicesTable, eq(serviceOrdersTable.serviceId, servicesTable.id))
|
||||
.where(eq(serviceOrdersTable.userId, id))
|
||||
.orderBy(sql`${serviceOrdersTable.createdAt} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin/users/:id/dependents/conversations",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureUserExists(id))) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseUserDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.createdBy, id));
|
||||
const items = await db
|
||||
.select({
|
||||
id: conversationsTable.id,
|
||||
nameAr: conversationsTable.nameAr,
|
||||
nameEn: conversationsTable.nameEn,
|
||||
isGroup: conversationsTable.isGroup,
|
||||
createdAt: conversationsTable.createdAt,
|
||||
})
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.createdBy, id))
|
||||
.orderBy(sql`${conversationsTable.updatedAt} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/admin/users/:id/dependents/messages",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
if (!(await ensureUserExists(id))) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = parseUserDependentsPaging(req);
|
||||
const [{ count: totalCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(messagesTable)
|
||||
.where(eq(messagesTable.senderId, id));
|
||||
// Join conversation context so the popover can show *where* the user
|
||||
// talked, not just an opaque list of message IDs.
|
||||
const items = await db
|
||||
.select({
|
||||
id: messagesTable.id,
|
||||
content: messagesTable.content,
|
||||
kind: messagesTable.kind,
|
||||
createdAt: messagesTable.createdAt,
|
||||
conversationId: conversationsTable.id,
|
||||
conversationNameAr: conversationsTable.nameAr,
|
||||
conversationNameEn: conversationsTable.nameEn,
|
||||
conversationIsGroup: conversationsTable.isGroup,
|
||||
})
|
||||
.from(messagesTable)
|
||||
.innerJoin(
|
||||
conversationsTable,
|
||||
eq(messagesTable.conversationId, conversationsTable.id),
|
||||
)
|
||||
.where(eq(messagesTable.senderId, id))
|
||||
.orderBy(sql`${messagesTable.createdAt} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const nextOffset = offset + items.length < totalCount ? offset + items.length : null;
|
||||
res.json({ items, totalCount, limit, offset, nextOffset });
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const params = GetUserParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -893,6 +893,58 @@
|
||||
"loadMoreError": "تعذّر تحميل المزيد من الفتحات.",
|
||||
"shownOf": "عرض {{shown}} من {{total}}"
|
||||
}
|
||||
},
|
||||
"dependents": {
|
||||
"openLabel": "عرض {{count}}",
|
||||
"loadError": "تعذّر تحميل العناصر.",
|
||||
"empty": "لا يوجد ما يُعرض.",
|
||||
"appGroups": {
|
||||
"title": "المجموعات · {{name}}",
|
||||
"subtitle": "{{count}} مجموعة تمنح صلاحية الوصول"
|
||||
},
|
||||
"appRestrictions": {
|
||||
"title": "القيود · {{name}}",
|
||||
"subtitle": "{{count}} صلاحية مطلوبة",
|
||||
"noRoles": "لا توجد أدوار تشمل هذه الصلاحية"
|
||||
},
|
||||
"appOpens": {
|
||||
"title": "الفتحات · {{name}}",
|
||||
"subtitle": "{{count}} فتحة إجمالاً"
|
||||
},
|
||||
"serviceOrders": {
|
||||
"title": "الطلبات · {{name}}",
|
||||
"subtitle": "{{count}} طلب"
|
||||
},
|
||||
"userNotes": {
|
||||
"title": "الملاحظات · {{name}}",
|
||||
"subtitle": "{{count}} ملاحظة",
|
||||
"untitled": "(بدون عنوان)",
|
||||
"pinned": "مثبّتة",
|
||||
"archived": "مؤرشفة"
|
||||
},
|
||||
"userOrders": {
|
||||
"title": "الطلبات · {{name}}",
|
||||
"subtitle": "{{count}} طلب"
|
||||
},
|
||||
"userConversations": {
|
||||
"title": "المحادثات · {{name}}",
|
||||
"subtitle": "{{count}} محادثة",
|
||||
"kindGroup": "مجموعة",
|
||||
"kindDirect": "خاصة",
|
||||
"unnamedGroup": "(مجموعة بدون اسم)",
|
||||
"unnamedDirect": "محادثة خاصة"
|
||||
},
|
||||
"userMessages": {
|
||||
"title": "الرسائل · {{name}}",
|
||||
"subtitle": "{{count}} رسالة"
|
||||
},
|
||||
"orderStatus": {
|
||||
"pending": "قيد الانتظار",
|
||||
"received": "مستلم",
|
||||
"preparing": "قيد التحضير",
|
||||
"completed": "مكتمل",
|
||||
"cancelled": "ملغى"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -794,6 +794,58 @@
|
||||
"loadMoreError": "Couldn't load more opens.",
|
||||
"shownOf": "Showing {{shown}} of {{total}}"
|
||||
}
|
||||
},
|
||||
"dependents": {
|
||||
"openLabel": "View {{count}}",
|
||||
"loadError": "Couldn't load items.",
|
||||
"empty": "Nothing to show.",
|
||||
"appGroups": {
|
||||
"title": "Groups · {{name}}",
|
||||
"subtitle": "{{count}} groups grant access"
|
||||
},
|
||||
"appRestrictions": {
|
||||
"title": "Restrictions · {{name}}",
|
||||
"subtitle": "{{count}} required permissions",
|
||||
"noRoles": "No roles include this permission"
|
||||
},
|
||||
"appOpens": {
|
||||
"title": "Opens · {{name}}",
|
||||
"subtitle": "{{count}} total opens"
|
||||
},
|
||||
"serviceOrders": {
|
||||
"title": "Orders · {{name}}",
|
||||
"subtitle": "{{count}} orders"
|
||||
},
|
||||
"userNotes": {
|
||||
"title": "Notes · {{name}}",
|
||||
"subtitle": "{{count}} notes",
|
||||
"untitled": "(untitled)",
|
||||
"pinned": "Pinned",
|
||||
"archived": "Archived"
|
||||
},
|
||||
"userOrders": {
|
||||
"title": "Orders · {{name}}",
|
||||
"subtitle": "{{count}} orders"
|
||||
},
|
||||
"userConversations": {
|
||||
"title": "Conversations · {{name}}",
|
||||
"subtitle": "{{count}} conversations",
|
||||
"kindGroup": "Group",
|
||||
"kindDirect": "Direct",
|
||||
"unnamedGroup": "(unnamed group)",
|
||||
"unnamedDirect": "Direct chat"
|
||||
},
|
||||
"userMessages": {
|
||||
"title": "Messages · {{name}}",
|
||||
"subtitle": "{{count}} messages"
|
||||
},
|
||||
"orderStatus": {
|
||||
"pending": "Pending",
|
||||
"received": "Received",
|
||||
"preparing": "Preparing",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -34,6 +34,30 @@ import {
|
||||
useGetAdminAppOpensByUser,
|
||||
getGetAdminAppOpensByUserQueryKey,
|
||||
getAdminAppOpensByUser,
|
||||
useGetAdminAppDependentGroups,
|
||||
getGetAdminAppDependentGroupsQueryKey,
|
||||
useGetAdminAppDependentRestrictions,
|
||||
getGetAdminAppDependentRestrictionsQueryKey,
|
||||
useGetAdminAppDependentOpens,
|
||||
getGetAdminAppDependentOpensQueryKey,
|
||||
useGetAdminServiceDependentOrders,
|
||||
getGetAdminServiceDependentOrdersQueryKey,
|
||||
useGetAdminUserDependentNotes,
|
||||
getGetAdminUserDependentNotesQueryKey,
|
||||
useGetAdminUserDependentOrders,
|
||||
getGetAdminUserDependentOrdersQueryKey,
|
||||
useGetAdminUserDependentConversations,
|
||||
getGetAdminUserDependentConversationsQueryKey,
|
||||
useGetAdminUserDependentMessages,
|
||||
getGetAdminUserDependentMessagesQueryKey,
|
||||
getAdminAppDependentGroups,
|
||||
getAdminAppDependentRestrictions,
|
||||
getAdminAppDependentOpens,
|
||||
getAdminServiceDependentOrders,
|
||||
getAdminUserDependentNotes,
|
||||
getAdminUserDependentOrders,
|
||||
getAdminUserDependentConversations,
|
||||
getAdminUserDependentMessages,
|
||||
useAdminIssueResetLink,
|
||||
useListGroups,
|
||||
getListGroupsQueryKey,
|
||||
@@ -99,6 +123,14 @@ import type {
|
||||
PermissionAuditEntry,
|
||||
PermissionAuditEntryChangeKind,
|
||||
Permission,
|
||||
AppDependentGroupsPage,
|
||||
AppDependentRestrictionsPage,
|
||||
AppDependentOpensPage,
|
||||
ServiceDependentOrdersPage,
|
||||
UserDependentNotesPage,
|
||||
UserDependentOrdersPage,
|
||||
UserDependentConversationsPage,
|
||||
UserDependentMessagesPage,
|
||||
} from "@workspace/api-client-react";
|
||||
import { ListUsersStatus } from "@workspace/api-client-react";
|
||||
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
@@ -754,6 +786,7 @@ export default function AdminPage() {
|
||||
|
||||
const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null);
|
||||
const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null);
|
||||
const [dependencyTarget, setDependencyTarget] = useState<DependencyTarget | null>(null);
|
||||
const [appDeleteTarget, setAppDeleteTarget] = useState<App | null>(null);
|
||||
const [appDeleteConflict, setAppDeleteConflict] = useState<AppDeletionConflict | null>(null);
|
||||
const [serviceDeleteTarget, setServiceDeleteTarget] = useState<Service | null>(null);
|
||||
@@ -1316,10 +1349,30 @@ export default function AdminPage() {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{app.route}</div>
|
||||
{(() => {
|
||||
const parts: string[] = [];
|
||||
if ((app.groupCount ?? 0) > 0) parts.push(t("admin.apps.counts.groups", { count: app.groupCount }));
|
||||
if ((app.restrictionCount ?? 0) > 0) parts.push(t("admin.apps.counts.restrictions", { count: app.restrictionCount }));
|
||||
if ((app.openCount ?? 0) > 0) parts.push(t("admin.apps.counts.opens", { count: app.openCount }));
|
||||
const appName = lang === "ar" ? app.nameAr : app.nameEn;
|
||||
const parts: Array<{
|
||||
label: string;
|
||||
target: DependencyTarget;
|
||||
testid: string;
|
||||
}> = [];
|
||||
if ((app.groupCount ?? 0) > 0)
|
||||
parts.push({
|
||||
label: t("admin.apps.counts.groups", { count: app.groupCount }),
|
||||
target: { kind: "appGroups", appId: app.id, appName },
|
||||
testid: `app-counts-groups-${app.id}`,
|
||||
});
|
||||
if ((app.restrictionCount ?? 0) > 0)
|
||||
parts.push({
|
||||
label: t("admin.apps.counts.restrictions", { count: app.restrictionCount }),
|
||||
target: { kind: "appRestrictions", appId: app.id, appName },
|
||||
testid: `app-counts-restrictions-${app.id}`,
|
||||
});
|
||||
if ((app.openCount ?? 0) > 0)
|
||||
parts.push({
|
||||
label: t("admin.apps.counts.opens", { count: app.openCount }),
|
||||
target: { kind: "appOpens", appId: app.id, appName },
|
||||
testid: `app-counts-opens-${app.id}`,
|
||||
});
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -1327,9 +1380,19 @@ export default function AdminPage() {
|
||||
data-testid={`app-counts-${app.id}`}
|
||||
>
|
||||
{parts.map((p, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
<span key={p.testid} className="flex items-center gap-2">
|
||||
{i > 0 && <span aria-hidden="true">•</span>}
|
||||
<span>{p}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDependencyTarget(p.target)}
|
||||
className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm hover:text-foreground transition-colors"
|
||||
data-testid={p.testid}
|
||||
aria-label={t("admin.dependents.openLabel", {
|
||||
count: p.label,
|
||||
})}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -1417,8 +1480,24 @@ export default function AdminPage() {
|
||||
<div
|
||||
className="text-[11px] text-muted-foreground pt-0.5"
|
||||
data-testid={`service-counts-${service.id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDependencyTarget({
|
||||
kind: "serviceOrders",
|
||||
serviceId: service.id,
|
||||
serviceName: lang === "ar" ? service.nameAr : service.nameEn,
|
||||
})
|
||||
}
|
||||
className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm hover:text-foreground transition-colors"
|
||||
data-testid={`service-counts-orders-${service.id}`}
|
||||
aria-label={t("admin.dependents.openLabel", {
|
||||
count: t("admin.services.counts.orders", { count: service.orderCount }),
|
||||
})}
|
||||
>
|
||||
{t("admin.services.counts.orders", { count: service.orderCount })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1619,6 +1698,13 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dependencyTarget && (
|
||||
<DependencyDrillIn
|
||||
target={dependencyTarget}
|
||||
lang={lang}
|
||||
onClose={() => setDependencyTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2737,6 +2823,495 @@ function UserOpensDrillIn({
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Dependency drill-in =====
|
||||
//
|
||||
// Generic shell + per-kind list rendering that turns each "<n> X" count
|
||||
// badge on the admin Apps / Services / Users rows into a clickable link
|
||||
// that reveals the actual rows behind the count. Reuses DrillInShell so
|
||||
// Escape-to-close + RTL/LTR layout already work; the counts themselves
|
||||
// are rendered as <button>s in the panels so keyboard focus + Enter open
|
||||
// the drill-in.
|
||||
type DependencyTarget =
|
||||
| { kind: "appGroups"; appId: number; appName: string }
|
||||
| { kind: "appRestrictions"; appId: number; appName: string }
|
||||
| { kind: "appOpens"; appId: number; appName: string }
|
||||
| { kind: "serviceOrders"; serviceId: number; serviceName: string }
|
||||
| { kind: "userNotes"; userId: number; userLabel: string }
|
||||
| { kind: "userOrders"; userId: number; userLabel: string }
|
||||
| { kind: "userConversations"; userId: number; userLabel: string }
|
||||
| { kind: "userMessages"; userId: number; userLabel: string };
|
||||
|
||||
type DependencyPage =
|
||||
| AppDependentGroupsPage
|
||||
| AppDependentRestrictionsPage
|
||||
| AppDependentOpensPage
|
||||
| ServiceDependentOrdersPage
|
||||
| UserDependentNotesPage
|
||||
| UserDependentOrdersPage
|
||||
| UserDependentConversationsPage
|
||||
| UserDependentMessagesPage;
|
||||
|
||||
const DEPENDENT_PAGE_LIMIT = 50;
|
||||
|
||||
function DependencyDrillIn({
|
||||
target,
|
||||
lang,
|
||||
onClose,
|
||||
}: {
|
||||
target: DependencyTarget;
|
||||
lang: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Each kind picks its own React Query hook + paginated fetcher so the
|
||||
// "load more" behavior matches the existing AppOpensDrillIn pattern.
|
||||
const initial = useDependencyInitial(target);
|
||||
type Item = DependencyPage["items"][number];
|
||||
const [extra, setExtra] = useState<Item[]>([]);
|
||||
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
|
||||
const [hasExtras, setHasExtras] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
|
||||
|
||||
// Reset paging state if the user reopens the drill on a different row
|
||||
// before this instance unmounts (state otherwise persists with the key).
|
||||
const targetKey = JSON.stringify(target);
|
||||
useEffect(() => {
|
||||
setExtra([]);
|
||||
setExtraNextOffset(null);
|
||||
setHasExtras(false);
|
||||
setLoadMoreError(null);
|
||||
setIsLoadingMore(false);
|
||||
}, [targetKey]);
|
||||
|
||||
const errorMessage = initial.error
|
||||
? (initial.error as { message?: string }).message ?? null
|
||||
: null;
|
||||
const data = initial.data;
|
||||
const items = data ? [...data.items, ...extra] : [];
|
||||
const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null;
|
||||
|
||||
const onLoadMore = async () => {
|
||||
if (nextOffset === null || isLoadingMore) return;
|
||||
setIsLoadingMore(true);
|
||||
setLoadMoreError(null);
|
||||
try {
|
||||
const next = await fetchDependentsPage(target, {
|
||||
limit: DEPENDENT_PAGE_LIMIT,
|
||||
offset: nextOffset,
|
||||
});
|
||||
setExtra((prev) => [...prev, ...(next.items as Item[])]);
|
||||
setExtraNextOffset(next.nextOffset);
|
||||
setHasExtras(true);
|
||||
} catch (e) {
|
||||
setLoadMoreError((e as { message?: string }).message ?? "Error");
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
const headings = describeDependencyTarget(t, target, totalCount);
|
||||
|
||||
return (
|
||||
<DrillInShell title={headings.title} subtitle={headings.subtitle} onClose={onClose}>
|
||||
{initial.isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
|
||||
{t("admin.dependents.loadError")}
|
||||
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">
|
||||
{errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
) : !data || items.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-8 text-center">
|
||||
{t("admin.dependents.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((item, idx) => (
|
||||
<li key={`${idx}-${(item as { id?: number }).id ?? idx}`}>
|
||||
<DependencyRow target={target} item={item} lang={lang} t={t} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<LoadMoreSection
|
||||
shownCount={items.length}
|
||||
totalCount={totalCount}
|
||||
nextOffset={nextOffset}
|
||||
isLoadingMore={isLoadingMore}
|
||||
loadMoreError={loadMoreError}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DrillInShell>
|
||||
);
|
||||
}
|
||||
|
||||
// React Query hooks must be called unconditionally and in a stable order,
|
||||
// so we always call all eight, but only the matching one is `enabled`.
|
||||
function useDependencyInitial(target: DependencyTarget): {
|
||||
data: DependencyPage | undefined;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
} {
|
||||
const params = { limit: DEPENDENT_PAGE_LIMIT, offset: 0 };
|
||||
const appId =
|
||||
target.kind === "appGroups" ||
|
||||
target.kind === "appRestrictions" ||
|
||||
target.kind === "appOpens"
|
||||
? target.appId
|
||||
: 0;
|
||||
const serviceId = target.kind === "serviceOrders" ? target.serviceId : 0;
|
||||
const userId =
|
||||
target.kind === "userNotes" ||
|
||||
target.kind === "userOrders" ||
|
||||
target.kind === "userConversations" ||
|
||||
target.kind === "userMessages"
|
||||
? target.userId
|
||||
: 0;
|
||||
|
||||
const enabledFor = (k: DependencyTarget["kind"]) => target.kind === k;
|
||||
|
||||
const groups = useGetAdminAppDependentGroups(appId, params, {
|
||||
query: {
|
||||
queryKey: getGetAdminAppDependentGroupsQueryKey(appId, params),
|
||||
enabled: enabledFor("appGroups"),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const restrictions = useGetAdminAppDependentRestrictions(appId, params, {
|
||||
query: {
|
||||
queryKey: getGetAdminAppDependentRestrictionsQueryKey(appId, params),
|
||||
enabled: enabledFor("appRestrictions"),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const opens = useGetAdminAppDependentOpens(appId, params, {
|
||||
query: {
|
||||
queryKey: getGetAdminAppDependentOpensQueryKey(appId, params),
|
||||
enabled: enabledFor("appOpens"),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const sOrders = useGetAdminServiceDependentOrders(serviceId, params, {
|
||||
query: {
|
||||
queryKey: getGetAdminServiceDependentOrdersQueryKey(serviceId, params),
|
||||
enabled: enabledFor("serviceOrders"),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const uNotes = useGetAdminUserDependentNotes(userId, params, {
|
||||
query: {
|
||||
queryKey: getGetAdminUserDependentNotesQueryKey(userId, params),
|
||||
enabled: enabledFor("userNotes"),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const uOrders = useGetAdminUserDependentOrders(userId, params, {
|
||||
query: {
|
||||
queryKey: getGetAdminUserDependentOrdersQueryKey(userId, params),
|
||||
enabled: enabledFor("userOrders"),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const uConvos = useGetAdminUserDependentConversations(userId, params, {
|
||||
query: {
|
||||
queryKey: getGetAdminUserDependentConversationsQueryKey(userId, params),
|
||||
enabled: enabledFor("userConversations"),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const uMessages = useGetAdminUserDependentMessages(userId, params, {
|
||||
query: {
|
||||
queryKey: getGetAdminUserDependentMessagesQueryKey(userId, params),
|
||||
enabled: enabledFor("userMessages"),
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
switch (target.kind) {
|
||||
case "appGroups":
|
||||
return groups;
|
||||
case "appRestrictions":
|
||||
return restrictions;
|
||||
case "appOpens":
|
||||
return opens;
|
||||
case "serviceOrders":
|
||||
return sOrders;
|
||||
case "userNotes":
|
||||
return uNotes;
|
||||
case "userOrders":
|
||||
return uOrders;
|
||||
case "userConversations":
|
||||
return uConvos;
|
||||
case "userMessages":
|
||||
return uMessages;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDependentsPage(
|
||||
target: DependencyTarget,
|
||||
params: { limit: number; offset: number },
|
||||
): Promise<DependencyPage> {
|
||||
switch (target.kind) {
|
||||
case "appGroups":
|
||||
return getAdminAppDependentGroups(target.appId, params);
|
||||
case "appRestrictions":
|
||||
return getAdminAppDependentRestrictions(target.appId, params);
|
||||
case "appOpens":
|
||||
return getAdminAppDependentOpens(target.appId, params);
|
||||
case "serviceOrders":
|
||||
return getAdminServiceDependentOrders(target.serviceId, params);
|
||||
case "userNotes":
|
||||
return getAdminUserDependentNotes(target.userId, params);
|
||||
case "userOrders":
|
||||
return getAdminUserDependentOrders(target.userId, params);
|
||||
case "userConversations":
|
||||
return getAdminUserDependentConversations(target.userId, params);
|
||||
case "userMessages":
|
||||
return getAdminUserDependentMessages(target.userId, params);
|
||||
}
|
||||
}
|
||||
|
||||
function describeDependencyTarget(
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
target: DependencyTarget,
|
||||
totalCount: number,
|
||||
): { title: string; subtitle: string } {
|
||||
switch (target.kind) {
|
||||
case "appGroups":
|
||||
return {
|
||||
title: t("admin.dependents.appGroups.title", { name: target.appName }),
|
||||
subtitle: t("admin.dependents.appGroups.subtitle", { count: totalCount }),
|
||||
};
|
||||
case "appRestrictions":
|
||||
return {
|
||||
title: t("admin.dependents.appRestrictions.title", { name: target.appName }),
|
||||
subtitle: t("admin.dependents.appRestrictions.subtitle", { count: totalCount }),
|
||||
};
|
||||
case "appOpens":
|
||||
return {
|
||||
title: t("admin.dependents.appOpens.title", { name: target.appName }),
|
||||
subtitle: t("admin.dependents.appOpens.subtitle", { count: totalCount }),
|
||||
};
|
||||
case "serviceOrders":
|
||||
return {
|
||||
title: t("admin.dependents.serviceOrders.title", { name: target.serviceName }),
|
||||
subtitle: t("admin.dependents.serviceOrders.subtitle", { count: totalCount }),
|
||||
};
|
||||
case "userNotes":
|
||||
return {
|
||||
title: t("admin.dependents.userNotes.title", { name: target.userLabel }),
|
||||
subtitle: t("admin.dependents.userNotes.subtitle", { count: totalCount }),
|
||||
};
|
||||
case "userOrders":
|
||||
return {
|
||||
title: t("admin.dependents.userOrders.title", { name: target.userLabel }),
|
||||
subtitle: t("admin.dependents.userOrders.subtitle", { count: totalCount }),
|
||||
};
|
||||
case "userConversations":
|
||||
return {
|
||||
title: t("admin.dependents.userConversations.title", { name: target.userLabel }),
|
||||
subtitle: t("admin.dependents.userConversations.subtitle", { count: totalCount }),
|
||||
};
|
||||
case "userMessages":
|
||||
return {
|
||||
title: t("admin.dependents.userMessages.title", { name: target.userLabel }),
|
||||
subtitle: t("admin.dependents.userMessages.subtitle", { count: totalCount }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function DependencyRow({
|
||||
target,
|
||||
item,
|
||||
lang,
|
||||
t,
|
||||
}: {
|
||||
target: DependencyTarget;
|
||||
item: DependencyPage["items"][number];
|
||||
lang: string;
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
}) {
|
||||
const baseRowClass =
|
||||
"flex items-start gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 text-start";
|
||||
|
||||
switch (target.kind) {
|
||||
case "appGroups": {
|
||||
const g = item as AppDependentGroupsPage["items"][number];
|
||||
const description = lang === "ar" ? g.descriptionAr : g.descriptionEn;
|
||||
return (
|
||||
<div className={baseRowClass}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{g.name}</div>
|
||||
{description && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "appRestrictions": {
|
||||
const p = item as AppDependentRestrictionsPage["items"][number];
|
||||
const description = lang === "ar" ? p.descriptionAr : p.descriptionEn;
|
||||
return (
|
||||
<div className={baseRowClass + " flex-col items-stretch"}>
|
||||
<div className="text-sm font-medium text-foreground truncate">{p.name}</div>
|
||||
{description && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">{description}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{p.roles.length === 0 ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("admin.dependents.appRestrictions.noRoles")}
|
||||
</span>
|
||||
) : (
|
||||
p.roles.map((r) => (
|
||||
<span
|
||||
key={r}
|
||||
className="text-[10px] px-2 py-0.5 rounded-full bg-primary/15 text-primary"
|
||||
>
|
||||
{r}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "appOpens": {
|
||||
const o = item as AppDependentOpensPage["items"][number];
|
||||
const display = (lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
|
||||
return (
|
||||
<div className={baseRowClass}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{display}</div>
|
||||
{display !== o.username && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">@{o.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "serviceOrders": {
|
||||
const o = item as ServiceDependentOrdersPage["items"][number];
|
||||
const display = (lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
|
||||
return (
|
||||
<div className={baseRowClass}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{display}</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{t(`admin.dependents.orderStatus.${o.status}`, {
|
||||
defaultValue: o.status,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "userNotes": {
|
||||
const n = item as UserDependentNotesPage["items"][number];
|
||||
const title = n.title.trim() || t("admin.dependents.userNotes.untitled");
|
||||
return (
|
||||
<div className={baseRowClass}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{title}</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{n.isPinned && <span className="me-2">{t("admin.dependents.userNotes.pinned")}</span>}
|
||||
{n.isArchived && <span>{t("admin.dependents.userNotes.archived")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(n.updatedAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "userOrders": {
|
||||
const o = item as UserDependentOrdersPage["items"][number];
|
||||
const serviceName = lang === "ar" ? o.serviceNameAr : o.serviceNameEn;
|
||||
return (
|
||||
<div className={baseRowClass}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{serviceName}</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{t(`admin.dependents.orderStatus.${o.status}`, {
|
||||
defaultValue: o.status,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "userConversations": {
|
||||
const c = item as UserDependentConversationsPage["items"][number];
|
||||
const name =
|
||||
(lang === "ar" ? c.nameAr : c.nameEn) ??
|
||||
t(
|
||||
c.isGroup
|
||||
? "admin.dependents.userConversations.unnamedGroup"
|
||||
: "admin.dependents.userConversations.unnamedDirect",
|
||||
);
|
||||
return (
|
||||
<div className={baseRowClass}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{name}</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{t(
|
||||
c.isGroup
|
||||
? "admin.dependents.userConversations.kindGroup"
|
||||
: "admin.dependents.userConversations.kindDirect",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(c.createdAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "userMessages": {
|
||||
const m = item as UserDependentMessagesPage["items"][number];
|
||||
const where =
|
||||
(lang === "ar" ? m.conversationNameAr : m.conversationNameEn) ??
|
||||
t(
|
||||
m.conversationIsGroup
|
||||
? "admin.dependents.userConversations.unnamedGroup"
|
||||
: "admin.dependents.userConversations.unnamedDirect",
|
||||
);
|
||||
// Trim to a single-line preview so a long message doesn't blow up
|
||||
// the popover height.
|
||||
const preview = m.content.length > 160 ? `${m.content.slice(0, 160)}…` : m.content;
|
||||
return (
|
||||
<div className={baseRowClass + " flex-col items-stretch"}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] text-muted-foreground truncate">{where}</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(m.createdAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-words">{preview}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Users Management Panel =====
|
||||
type UserSortKey = "username" | "email" | "createdAt";
|
||||
|
||||
@@ -2751,7 +3326,8 @@ function UsersPanel({
|
||||
userRowRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
|
||||
onIssueResetLink: (userId: number, username: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -2763,6 +3339,7 @@ function UsersPanel({
|
||||
const [userDeleteTarget, setUserDeleteTarget] = useState<UserProfile | null>(null);
|
||||
const [userDeleteConflict, setUserDeleteConflict] = useState<UserDeletionConflict | null>(null);
|
||||
const [newUserOpen, setNewUserOpen] = useState(false);
|
||||
const [dependencyTarget, setDependencyTarget] = useState<DependencyTarget | null>(null);
|
||||
const [newUserForm, setNewUserForm] = useState<{
|
||||
username: string;
|
||||
email: string;
|
||||
@@ -2915,11 +3492,39 @@ function UsersPanel({
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const parts: string[] = [];
|
||||
if ((u.noteCount ?? 0) > 0) parts.push(t("admin.users.counts.notes", { count: u.noteCount }));
|
||||
if ((u.orderCount ?? 0) > 0) parts.push(t("admin.users.counts.orders", { count: u.orderCount }));
|
||||
if ((u.conversationCount ?? 0) > 0) parts.push(t("admin.users.counts.conversations", { count: u.conversationCount }));
|
||||
if ((u.messageCount ?? 0) > 0) parts.push(t("admin.users.counts.messages", { count: u.messageCount }));
|
||||
const userLabel =
|
||||
(lang === "ar"
|
||||
? u.displayNameAr || u.displayNameEn
|
||||
: u.displayNameEn || u.displayNameAr) || u.username;
|
||||
const parts: Array<{
|
||||
label: string;
|
||||
target: DependencyTarget;
|
||||
testid: string;
|
||||
}> = [];
|
||||
if ((u.noteCount ?? 0) > 0)
|
||||
parts.push({
|
||||
label: t("admin.users.counts.notes", { count: u.noteCount }),
|
||||
target: { kind: "userNotes", userId: u.id, userLabel },
|
||||
testid: `user-counts-notes-${u.id}`,
|
||||
});
|
||||
if ((u.orderCount ?? 0) > 0)
|
||||
parts.push({
|
||||
label: t("admin.users.counts.orders", { count: u.orderCount }),
|
||||
target: { kind: "userOrders", userId: u.id, userLabel },
|
||||
testid: `user-counts-orders-${u.id}`,
|
||||
});
|
||||
if ((u.conversationCount ?? 0) > 0)
|
||||
parts.push({
|
||||
label: t("admin.users.counts.conversations", { count: u.conversationCount }),
|
||||
target: { kind: "userConversations", userId: u.id, userLabel },
|
||||
testid: `user-counts-conversations-${u.id}`,
|
||||
});
|
||||
if ((u.messageCount ?? 0) > 0)
|
||||
parts.push({
|
||||
label: t("admin.users.counts.messages", { count: u.messageCount }),
|
||||
target: { kind: "userMessages", userId: u.id, userLabel },
|
||||
testid: `user-counts-messages-${u.id}`,
|
||||
});
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -2927,9 +3532,17 @@ function UsersPanel({
|
||||
data-testid={`user-counts-${u.id}`}
|
||||
>
|
||||
{parts.map((p, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
<span key={p.testid} className="flex items-center gap-2">
|
||||
{i > 0 && <span aria-hidden="true">•</span>}
|
||||
<span>{p}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDependencyTarget(p.target)}
|
||||
className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm hover:text-foreground transition-colors"
|
||||
data-testid={p.testid}
|
||||
aria-label={t("admin.dependents.openLabel", { count: p.label })}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -3155,6 +3768,13 @@ function UsersPanel({
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{dependencyTarget && (
|
||||
<DependencyDrillIn
|
||||
target={dependencyTarget}
|
||||
lang={lang}
|
||||
onClose={() => setDependencyTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -912,6 +912,167 @@ export interface AdminAppOpensByUser {
|
||||
opens: AppOpenByUserEntry[];
|
||||
}
|
||||
|
||||
export interface AppDependentGroupItem {
|
||||
id: number;
|
||||
name: string;
|
||||
/** @nullable */
|
||||
descriptionAr: string | null;
|
||||
/** @nullable */
|
||||
descriptionEn: string | null;
|
||||
}
|
||||
|
||||
export interface AppDependentGroupsPage {
|
||||
items: AppDependentGroupItem[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface AppDependentRestrictionItem {
|
||||
id: number;
|
||||
name: string;
|
||||
/** @nullable */
|
||||
descriptionAr: string | null;
|
||||
/** @nullable */
|
||||
descriptionEn: string | null;
|
||||
/** Names of roles currently holding this permission. */
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface AppDependentRestrictionsPage {
|
||||
items: AppDependentRestrictionItem[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface AppDependentOpenItem {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
/** @nullable */
|
||||
displayNameAr: string | null;
|
||||
/** @nullable */
|
||||
displayNameEn: string | null;
|
||||
/** @nullable */
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface AppDependentOpensPage {
|
||||
items: AppDependentOpenItem[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface ServiceDependentOrderItem {
|
||||
id: number;
|
||||
status: string;
|
||||
/** @nullable */
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
/** @nullable */
|
||||
displayNameAr: string | null;
|
||||
/** @nullable */
|
||||
displayNameEn: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceDependentOrdersPage {
|
||||
items: ServiceDependentOrderItem[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface UserDependentNoteItem {
|
||||
id: number;
|
||||
title: string;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserDependentNotesPage {
|
||||
items: UserDependentNoteItem[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface UserDependentOrderItem {
|
||||
id: number;
|
||||
status: string;
|
||||
/** @nullable */
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
serviceId: number;
|
||||
serviceNameAr: string;
|
||||
serviceNameEn: string;
|
||||
}
|
||||
|
||||
export interface UserDependentOrdersPage {
|
||||
items: UserDependentOrderItem[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface UserDependentConversationItem {
|
||||
id: number;
|
||||
/** @nullable */
|
||||
nameAr: string | null;
|
||||
/** @nullable */
|
||||
nameEn: string | null;
|
||||
isGroup: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserDependentConversationsPage {
|
||||
items: UserDependentConversationItem[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface UserDependentMessageItem {
|
||||
id: number;
|
||||
content: string;
|
||||
kind: string;
|
||||
createdAt: string;
|
||||
conversationId: number;
|
||||
/** @nullable */
|
||||
conversationNameAr: string | null;
|
||||
/** @nullable */
|
||||
conversationNameEn: string | null;
|
||||
conversationIsGroup: boolean;
|
||||
}
|
||||
|
||||
export interface UserDependentMessagesPage {
|
||||
items: UserDependentMessageItem[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface AuditLogActor {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -1327,3 +1488,99 @@ Combines with `targetId` and the other filters.
|
||||
*/
|
||||
actorUserId?: number;
|
||||
};
|
||||
|
||||
export type GetAdminAppDependentGroupsParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminAppDependentRestrictionsParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminAppDependentOpensParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminServiceDependentOrdersParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminUserDependentNotesParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminUserDependentOrdersParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminUserDependentConversationsParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminUserDependentMessagesParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2465,6 +2465,275 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/apps/{id}/dependents/groups:
|
||||
get:
|
||||
operationId: getAdminAppDependentGroups
|
||||
tags: [apps]
|
||||
summary: List groups granting access to an app
|
||||
description: |
|
||||
Returns the groups behind the inline "<n> groups" badge on the
|
||||
admin Apps row, paginated. Admin only.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of groups granting the app
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDependentGroupsPage"
|
||||
"404":
|
||||
description: App not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/apps/{id}/dependents/restrictions:
|
||||
get:
|
||||
operationId: getAdminAppDependentRestrictions
|
||||
tags: [apps]
|
||||
summary: List permission restrictions on an app
|
||||
description: |
|
||||
Returns the legacy permissions gating an app, with the role names
|
||||
currently holding each permission, paginated. Admin only.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of restrictions on the app
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDependentRestrictionsPage"
|
||||
"404":
|
||||
description: App not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/apps/{id}/dependents/opens:
|
||||
get:
|
||||
operationId: getAdminAppDependentOpens
|
||||
tags: [apps]
|
||||
summary: List recent opens for an app (no time filter)
|
||||
description: |
|
||||
Returns every recorded open for the app newest-first, paginated.
|
||||
Unlike /stats/admin/app-opens/by-app/{appId}, this endpoint does
|
||||
not apply a date range so the badge total and the list match.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of opens
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDependentOpensPage"
|
||||
"404":
|
||||
description: App not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/services/{id}/dependents/orders:
|
||||
get:
|
||||
operationId: getAdminServiceDependentOrders
|
||||
tags: [services]
|
||||
summary: List orders placed against a service
|
||||
description: |
|
||||
Returns the service orders behind the inline "<n> orders" badge
|
||||
on the admin Services row, paginated, newest-first. Admin only.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of orders for the service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceDependentOrdersPage"
|
||||
"404":
|
||||
description: Service not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/users/{id}/dependents/notes:
|
||||
get:
|
||||
operationId: getAdminUserDependentNotes
|
||||
tags: [users]
|
||||
summary: List notes owned by a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of notes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDependentNotesPage"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/users/{id}/dependents/orders:
|
||||
get:
|
||||
operationId: getAdminUserDependentOrders
|
||||
tags: [users]
|
||||
summary: List service orders placed by a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of orders
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDependentOrdersPage"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/users/{id}/dependents/conversations:
|
||||
get:
|
||||
operationId: getAdminUserDependentConversations
|
||||
tags: [users]
|
||||
summary: List conversations created by a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of conversations
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDependentConversationsPage"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/users/{id}/dependents/messages:
|
||||
get:
|
||||
operationId: getAdminUserDependentMessages
|
||||
tags: [users]
|
||||
summary: List messages sent by a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of messages
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDependentMessagesPage"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HealthStatus:
|
||||
@@ -4098,6 +4367,208 @@ components:
|
||||
- nextOffset
|
||||
- opens
|
||||
|
||||
# ===== Dependent-item drill-ins =====
|
||||
# Each "<n> X" badge on the admin Apps / Services / Users rows is now
|
||||
# clickable; the click loads one of the schemas below. They share the
|
||||
# same envelope (totalCount + limit/offset/nextOffset) so the frontend
|
||||
# can use a single LoadMoreSection for them all.
|
||||
|
||||
AppDependentGroupItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
descriptionAr: { type: ["string", "null"] }
|
||||
descriptionEn: { type: ["string", "null"] }
|
||||
required: [id, name, descriptionAr, descriptionEn]
|
||||
|
||||
AppDependentGroupsPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppDependentGroupItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
AppDependentRestrictionItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
descriptionAr: { type: ["string", "null"] }
|
||||
descriptionEn: { type: ["string", "null"] }
|
||||
roles:
|
||||
type: array
|
||||
description: Names of roles currently holding this permission.
|
||||
items: { type: string }
|
||||
required: [id, name, descriptionAr, descriptionEn, roles]
|
||||
|
||||
AppDependentRestrictionsPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppDependentRestrictionItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
AppDependentOpenItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
createdAt: { type: string, format: date-time }
|
||||
userId: { type: integer }
|
||||
username: { type: string }
|
||||
displayNameAr: { type: ["string", "null"] }
|
||||
displayNameEn: { type: ["string", "null"] }
|
||||
avatarUrl: { type: ["string", "null"] }
|
||||
required: [id, createdAt, userId, username, displayNameAr, displayNameEn, avatarUrl]
|
||||
|
||||
AppDependentOpensPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppDependentOpenItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
ServiceDependentOrderItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
status: { type: string }
|
||||
notes: { type: ["string", "null"] }
|
||||
createdAt: { type: string, format: date-time }
|
||||
userId: { type: integer }
|
||||
username: { type: string }
|
||||
displayNameAr: { type: ["string", "null"] }
|
||||
displayNameEn: { type: ["string", "null"] }
|
||||
required: [id, status, notes, createdAt, userId, username, displayNameAr, displayNameEn]
|
||||
|
||||
ServiceDependentOrdersPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ServiceDependentOrderItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
UserDependentNoteItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
title: { type: string }
|
||||
isPinned: { type: boolean }
|
||||
isArchived: { type: boolean }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
required: [id, title, isPinned, isArchived, updatedAt]
|
||||
|
||||
UserDependentNotesPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserDependentNoteItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
UserDependentOrderItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
status: { type: string }
|
||||
notes: { type: ["string", "null"] }
|
||||
createdAt: { type: string, format: date-time }
|
||||
serviceId: { type: integer }
|
||||
serviceNameAr: { type: string }
|
||||
serviceNameEn: { type: string }
|
||||
required: [id, status, notes, createdAt, serviceId, serviceNameAr, serviceNameEn]
|
||||
|
||||
UserDependentOrdersPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserDependentOrderItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
UserDependentConversationItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
nameAr: { type: ["string", "null"] }
|
||||
nameEn: { type: ["string", "null"] }
|
||||
isGroup: { type: boolean }
|
||||
createdAt: { type: string, format: date-time }
|
||||
required: [id, nameAr, nameEn, isGroup, createdAt]
|
||||
|
||||
UserDependentConversationsPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserDependentConversationItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
UserDependentMessageItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
content: { type: string }
|
||||
kind: { type: string }
|
||||
createdAt: { type: string, format: date-time }
|
||||
conversationId: { type: integer }
|
||||
conversationNameAr: { type: ["string", "null"] }
|
||||
conversationNameEn: { type: ["string", "null"] }
|
||||
conversationIsGroup: { type: boolean }
|
||||
required: [id, content, kind, createdAt, conversationId, conversationNameAr, conversationNameEn, conversationIsGroup]
|
||||
|
||||
UserDependentMessagesPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserDependentMessageItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
TopUserItem:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -2884,3 +2884,355 @@ export const ExportAuditLogsCsvQueryParams = zod.object({
|
||||
"Restrict the export to entries written by a specific acting user.\n",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the groups behind the inline "<n> groups" badge on the
|
||||
admin Apps row, paginated. Admin only.
|
||||
|
||||
* @summary List groups granting access to an app
|
||||
*/
|
||||
export const GetAdminAppDependentGroupsParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAdminAppDependentGroupsQueryLimitDefault = 50;
|
||||
export const getAdminAppDependentGroupsQueryLimitMax = 200;
|
||||
|
||||
export const getAdminAppDependentGroupsQueryOffsetDefault = 0;
|
||||
export const getAdminAppDependentGroupsQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminAppDependentGroupsQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminAppDependentGroupsQueryLimitMax)
|
||||
.default(getAdminAppDependentGroupsQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminAppDependentGroupsQueryOffsetMin)
|
||||
.default(getAdminAppDependentGroupsQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminAppDependentGroupsResponse = zod.object({
|
||||
items: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
name: zod.string(),
|
||||
descriptionAr: zod.string().nullable(),
|
||||
descriptionEn: zod.string().nullable(),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the legacy permissions gating an app, with the role names
|
||||
currently holding each permission, paginated. Admin only.
|
||||
|
||||
* @summary List permission restrictions on an app
|
||||
*/
|
||||
export const GetAdminAppDependentRestrictionsParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAdminAppDependentRestrictionsQueryLimitDefault = 50;
|
||||
export const getAdminAppDependentRestrictionsQueryLimitMax = 200;
|
||||
|
||||
export const getAdminAppDependentRestrictionsQueryOffsetDefault = 0;
|
||||
export const getAdminAppDependentRestrictionsQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminAppDependentRestrictionsQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminAppDependentRestrictionsQueryLimitMax)
|
||||
.default(getAdminAppDependentRestrictionsQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminAppDependentRestrictionsQueryOffsetMin)
|
||||
.default(getAdminAppDependentRestrictionsQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminAppDependentRestrictionsResponse = zod.object({
|
||||
items: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
name: zod.string(),
|
||||
descriptionAr: zod.string().nullable(),
|
||||
descriptionEn: zod.string().nullable(),
|
||||
roles: zod
|
||||
.array(zod.string())
|
||||
.describe("Names of roles currently holding this permission."),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns every recorded open for the app newest-first, paginated.
|
||||
Unlike /stats/admin/app-opens/by-app/{appId}, this endpoint does
|
||||
not apply a date range so the badge total and the list match.
|
||||
|
||||
* @summary List recent opens for an app (no time filter)
|
||||
*/
|
||||
export const GetAdminAppDependentOpensParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAdminAppDependentOpensQueryLimitDefault = 50;
|
||||
export const getAdminAppDependentOpensQueryLimitMax = 200;
|
||||
|
||||
export const getAdminAppDependentOpensQueryOffsetDefault = 0;
|
||||
export const getAdminAppDependentOpensQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminAppDependentOpensQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminAppDependentOpensQueryLimitMax)
|
||||
.default(getAdminAppDependentOpensQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminAppDependentOpensQueryOffsetMin)
|
||||
.default(getAdminAppDependentOpensQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminAppDependentOpensResponse = zod.object({
|
||||
items: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
userId: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullable(),
|
||||
displayNameEn: zod.string().nullable(),
|
||||
avatarUrl: zod.string().nullable(),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the service orders behind the inline "<n> orders" badge
|
||||
on the admin Services row, paginated, newest-first. Admin only.
|
||||
|
||||
* @summary List orders placed against a service
|
||||
*/
|
||||
export const GetAdminServiceDependentOrdersParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAdminServiceDependentOrdersQueryLimitDefault = 50;
|
||||
export const getAdminServiceDependentOrdersQueryLimitMax = 200;
|
||||
|
||||
export const getAdminServiceDependentOrdersQueryOffsetDefault = 0;
|
||||
export const getAdminServiceDependentOrdersQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminServiceDependentOrdersQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminServiceDependentOrdersQueryLimitMax)
|
||||
.default(getAdminServiceDependentOrdersQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminServiceDependentOrdersQueryOffsetMin)
|
||||
.default(getAdminServiceDependentOrdersQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminServiceDependentOrdersResponse = zod.object({
|
||||
items: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
status: zod.string(),
|
||||
notes: zod.string().nullable(),
|
||||
createdAt: zod.coerce.date(),
|
||||
userId: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullable(),
|
||||
displayNameEn: zod.string().nullable(),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List notes owned by a user
|
||||
*/
|
||||
export const GetAdminUserDependentNotesParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAdminUserDependentNotesQueryLimitDefault = 50;
|
||||
export const getAdminUserDependentNotesQueryLimitMax = 200;
|
||||
|
||||
export const getAdminUserDependentNotesQueryOffsetDefault = 0;
|
||||
export const getAdminUserDependentNotesQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminUserDependentNotesQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminUserDependentNotesQueryLimitMax)
|
||||
.default(getAdminUserDependentNotesQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminUserDependentNotesQueryOffsetMin)
|
||||
.default(getAdminUserDependentNotesQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminUserDependentNotesResponse = zod.object({
|
||||
items: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
title: zod.string(),
|
||||
isPinned: zod.boolean(),
|
||||
isArchived: zod.boolean(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List service orders placed by a user
|
||||
*/
|
||||
export const GetAdminUserDependentOrdersParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAdminUserDependentOrdersQueryLimitDefault = 50;
|
||||
export const getAdminUserDependentOrdersQueryLimitMax = 200;
|
||||
|
||||
export const getAdminUserDependentOrdersQueryOffsetDefault = 0;
|
||||
export const getAdminUserDependentOrdersQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminUserDependentOrdersQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminUserDependentOrdersQueryLimitMax)
|
||||
.default(getAdminUserDependentOrdersQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminUserDependentOrdersQueryOffsetMin)
|
||||
.default(getAdminUserDependentOrdersQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminUserDependentOrdersResponse = zod.object({
|
||||
items: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
status: zod.string(),
|
||||
notes: zod.string().nullable(),
|
||||
createdAt: zod.coerce.date(),
|
||||
serviceId: zod.number(),
|
||||
serviceNameAr: zod.string(),
|
||||
serviceNameEn: zod.string(),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List conversations created by a user
|
||||
*/
|
||||
export const GetAdminUserDependentConversationsParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAdminUserDependentConversationsQueryLimitDefault = 50;
|
||||
export const getAdminUserDependentConversationsQueryLimitMax = 200;
|
||||
|
||||
export const getAdminUserDependentConversationsQueryOffsetDefault = 0;
|
||||
export const getAdminUserDependentConversationsQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminUserDependentConversationsQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminUserDependentConversationsQueryLimitMax)
|
||||
.default(getAdminUserDependentConversationsQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminUserDependentConversationsQueryOffsetMin)
|
||||
.default(getAdminUserDependentConversationsQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminUserDependentConversationsResponse = zod.object({
|
||||
items: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
nameAr: zod.string().nullable(),
|
||||
nameEn: zod.string().nullable(),
|
||||
isGroup: zod.boolean(),
|
||||
createdAt: zod.coerce.date(),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List messages sent by a user
|
||||
*/
|
||||
export const GetAdminUserDependentMessagesParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAdminUserDependentMessagesQueryLimitDefault = 50;
|
||||
export const getAdminUserDependentMessagesQueryLimitMax = 200;
|
||||
|
||||
export const getAdminUserDependentMessagesQueryOffsetDefault = 0;
|
||||
export const getAdminUserDependentMessagesQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminUserDependentMessagesQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminUserDependentMessagesQueryLimitMax)
|
||||
.default(getAdminUserDependentMessagesQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminUserDependentMessagesQueryOffsetMin)
|
||||
.default(getAdminUserDependentMessagesQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminUserDependentMessagesResponse = zod.object({
|
||||
items: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
content: zod.string(),
|
||||
kind: zod.string(),
|
||||
createdAt: zod.coerce.date(),
|
||||
conversationId: zod.number(),
|
||||
conversationNameAr: zod.string().nullable(),
|
||||
conversationNameEn: zod.string().nullable(),
|
||||
conversationIsGroup: zod.boolean(),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user