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:
riyadhafraa
2026-05-01 07:32:11 +00:00
parent b8ffe460ef
commit c0e55752a3
11 changed files with 3246 additions and 15 deletions
+176
View File
@@ -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) {
+193
View File
@@ -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

+52
View File
@@ -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": {
+52
View File
@@ -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": {
+634 -14
View File
@@ -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
+471
View File
@@ -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:
+352
View File
@@ -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(),
});