diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index ee4390c5..50ba7b1e 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -418,6 +418,182 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise => { res.json(app); }); +// ===== Dependent-item drill-ins ===== +// +// Each " 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 }): { + 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 { + 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 => { + 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`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 => { + 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`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(); + 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 => { + 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`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 => { const params = GetAppParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/api-server/src/routes/services.ts b/artifacts/api-server/src/routes/services.ts index b4e5e80f..de44201b 100644 --- a/artifacts/api-server/src/routes/services.ts +++ b/artifacts/api-server/src/routes/services.ts @@ -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 => { res.json(service); }); +// Drill-in for the " 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 => { + 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`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 => { const params = DeleteServiceParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index 3d399da9..c12063ef 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -9,6 +9,7 @@ import { userGroupsTable, notesTable, serviceOrdersTable, + servicesTable, conversationsTable, messagesTable, auditLogsTable, @@ -369,6 +370,198 @@ router.get( }, ); +// ===== Dependent-item drill-ins ===== +// +// Each " 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 }): { + 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 { + 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 => { + 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`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 => { + 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`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 => { + 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`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 => { + 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`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 => { const params = GetUserParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 09a08a76..4c29302d 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 37d84215..3eccb710 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": { diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 9bc06c83..5f69615e 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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": { diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 3753f3b2..9923b84f 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -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(null); const [appDeleteTarget, setAppDeleteTarget] = useState(null); const [appDeleteConflict, setAppDeleteConflict] = useState(null); const [serviceDeleteTarget, setServiceDeleteTarget] = useState(null); @@ -1316,10 +1349,30 @@ export default function AdminPage() {
{app.route}
{(() => { - 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 (
{parts.map((p, i) => ( - + {i > 0 && } - {p} + ))}
@@ -1418,7 +1481,23 @@ export default function AdminPage() { className="text-[11px] text-muted-foreground pt-0.5" data-testid={`service-counts-${service.id}`} > - {t("admin.services.counts.orders", { count: service.orderCount })} + )} @@ -1619,6 +1698,13 @@ export default function AdminPage() { )} + {dependencyTarget && ( + setDependencyTarget(null)} + /> + )} ); } @@ -2737,6 +2823,495 @@ function UserOpensDrillIn({ ); } +// ===== Dependency drill-in ===== +// +// Generic shell + per-kind list rendering that turns each " 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 ))} @@ -3155,6 +3768,13 @@ function UsersPanel({ /> ); })()} + {dependencyTarget && ( + setDependencyTarget(null)} + /> + )} ); } diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 460bcbc9..d13ff98e 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; +}; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 96b6c408..e8755ece 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -26,6 +26,9 @@ import type { AdminStats, App, AppDeletionConflict, + AppDependentGroupsPage, + AppDependentOpensPage, + AppDependentRestrictionsPage, AppPermissionLink, AppPermissionsImpact, AppPermissionsImpactBody, @@ -48,9 +51,17 @@ import type { ExportAuditLogsCsvParams, ForgotPasswordBody, ForgotPasswordResponse, + GetAdminAppDependentGroupsParams, + GetAdminAppDependentOpensParams, + GetAdminAppDependentRestrictionsParams, GetAdminAppOpensByAppParams, GetAdminAppOpensByUserParams, + GetAdminServiceDependentOrdersParams, GetAdminStatsParams, + GetAdminUserDependentConversationsParams, + GetAdminUserDependentMessagesParams, + GetAdminUserDependentNotesParams, + GetAdminUserDependentOrdersParams, GetAppPermissionAuditParams, GetGroupPermissionAuditParams, GetRolePermissionAuditParams, @@ -84,6 +95,7 @@ import type { Service, ServiceCategory, ServiceDeletionConflict, + ServiceDependentOrdersPage, ServiceOrder, SuccessResponse, UpdateAppBody, @@ -100,6 +112,10 @@ import type { UpdateServiceOrderStatusBody, UpdateUserBody, UserDeletionConflict, + UserDependentConversationsPage, + UserDependentMessagesPage, + UserDependentNotesPage, + UserDependentOrdersPage, UserProfile, VerifyResetTokenBody, VerifyResetTokenResponse, @@ -7423,3 +7439,986 @@ export function useExportAuditLogsCsv< return { ...query, queryKey: queryOptions.queryKey }; } + +/** + * Returns the groups behind the inline " groups" badge on the +admin Apps row, paginated. Admin only. + + * @summary List groups granting access to an app + */ +export const getGetAdminAppDependentGroupsUrl = ( + id: number, + params?: GetAdminAppDependentGroupsParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/apps/${id}/dependents/groups?${stringifiedParams}` + : `/api/admin/apps/${id}/dependents/groups`; +}; + +export const getAdminAppDependentGroups = async ( + id: number, + params?: GetAdminAppDependentGroupsParams, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAdminAppDependentGroupsUrl(id, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAdminAppDependentGroupsQueryKey = ( + id: number, + params?: GetAdminAppDependentGroupsParams, +) => { + return [ + `/api/admin/apps/${id}/dependents/groups`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetAdminAppDependentGroupsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminAppDependentGroupsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getGetAdminAppDependentGroupsQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAdminAppDependentGroups(id, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminAppDependentGroupsQueryResult = NonNullable< + Awaited> +>; +export type GetAdminAppDependentGroupsQueryError = ErrorType; + +/** + * @summary List groups granting access to an app + */ + +export function useGetAdminAppDependentGroups< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminAppDependentGroupsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminAppDependentGroupsQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * 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 getGetAdminAppDependentRestrictionsUrl = ( + id: number, + params?: GetAdminAppDependentRestrictionsParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/apps/${id}/dependents/restrictions?${stringifiedParams}` + : `/api/admin/apps/${id}/dependents/restrictions`; +}; + +export const getAdminAppDependentRestrictions = async ( + id: number, + params?: GetAdminAppDependentRestrictionsParams, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAdminAppDependentRestrictionsUrl(id, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAdminAppDependentRestrictionsQueryKey = ( + id: number, + params?: GetAdminAppDependentRestrictionsParams, +) => { + return [ + `/api/admin/apps/${id}/dependents/restrictions`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetAdminAppDependentRestrictionsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminAppDependentRestrictionsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetAdminAppDependentRestrictionsQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAdminAppDependentRestrictions(id, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminAppDependentRestrictionsQueryResult = NonNullable< + Awaited> +>; +export type GetAdminAppDependentRestrictionsQueryError = + ErrorType; + +/** + * @summary List permission restrictions on an app + */ + +export function useGetAdminAppDependentRestrictions< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminAppDependentRestrictionsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminAppDependentRestrictionsQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * 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 getGetAdminAppDependentOpensUrl = ( + id: number, + params?: GetAdminAppDependentOpensParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/apps/${id}/dependents/opens?${stringifiedParams}` + : `/api/admin/apps/${id}/dependents/opens`; +}; + +export const getAdminAppDependentOpens = async ( + id: number, + params?: GetAdminAppDependentOpensParams, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAdminAppDependentOpensUrl(id, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAdminAppDependentOpensQueryKey = ( + id: number, + params?: GetAdminAppDependentOpensParams, +) => { + return [ + `/api/admin/apps/${id}/dependents/opens`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetAdminAppDependentOpensQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminAppDependentOpensParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getGetAdminAppDependentOpensQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAdminAppDependentOpens(id, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminAppDependentOpensQueryResult = NonNullable< + Awaited> +>; +export type GetAdminAppDependentOpensQueryError = ErrorType; + +/** + * @summary List recent opens for an app (no time filter) + */ + +export function useGetAdminAppDependentOpens< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminAppDependentOpensParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminAppDependentOpensQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Returns the service orders behind the inline " orders" badge +on the admin Services row, paginated, newest-first. Admin only. + + * @summary List orders placed against a service + */ +export const getGetAdminServiceDependentOrdersUrl = ( + id: number, + params?: GetAdminServiceDependentOrdersParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/services/${id}/dependents/orders?${stringifiedParams}` + : `/api/admin/services/${id}/dependents/orders`; +}; + +export const getAdminServiceDependentOrders = async ( + id: number, + params?: GetAdminServiceDependentOrdersParams, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAdminServiceDependentOrdersUrl(id, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAdminServiceDependentOrdersQueryKey = ( + id: number, + params?: GetAdminServiceDependentOrdersParams, +) => { + return [ + `/api/admin/services/${id}/dependents/orders`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetAdminServiceDependentOrdersQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminServiceDependentOrdersParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetAdminServiceDependentOrdersQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAdminServiceDependentOrders(id, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminServiceDependentOrdersQueryResult = NonNullable< + Awaited> +>; +export type GetAdminServiceDependentOrdersQueryError = ErrorType; + +/** + * @summary List orders placed against a service + */ + +export function useGetAdminServiceDependentOrders< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminServiceDependentOrdersParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminServiceDependentOrdersQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary List notes owned by a user + */ +export const getGetAdminUserDependentNotesUrl = ( + id: number, + params?: GetAdminUserDependentNotesParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/users/${id}/dependents/notes?${stringifiedParams}` + : `/api/admin/users/${id}/dependents/notes`; +}; + +export const getAdminUserDependentNotes = async ( + id: number, + params?: GetAdminUserDependentNotesParams, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAdminUserDependentNotesUrl(id, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAdminUserDependentNotesQueryKey = ( + id: number, + params?: GetAdminUserDependentNotesParams, +) => { + return [ + `/api/admin/users/${id}/dependents/notes`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetAdminUserDependentNotesQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminUserDependentNotesParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getGetAdminUserDependentNotesQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAdminUserDependentNotes(id, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminUserDependentNotesQueryResult = NonNullable< + Awaited> +>; +export type GetAdminUserDependentNotesQueryError = ErrorType; + +/** + * @summary List notes owned by a user + */ + +export function useGetAdminUserDependentNotes< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminUserDependentNotesParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminUserDependentNotesQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary List service orders placed by a user + */ +export const getGetAdminUserDependentOrdersUrl = ( + id: number, + params?: GetAdminUserDependentOrdersParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/users/${id}/dependents/orders?${stringifiedParams}` + : `/api/admin/users/${id}/dependents/orders`; +}; + +export const getAdminUserDependentOrders = async ( + id: number, + params?: GetAdminUserDependentOrdersParams, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAdminUserDependentOrdersUrl(id, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAdminUserDependentOrdersQueryKey = ( + id: number, + params?: GetAdminUserDependentOrdersParams, +) => { + return [ + `/api/admin/users/${id}/dependents/orders`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetAdminUserDependentOrdersQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminUserDependentOrdersParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetAdminUserDependentOrdersQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAdminUserDependentOrders(id, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminUserDependentOrdersQueryResult = NonNullable< + Awaited> +>; +export type GetAdminUserDependentOrdersQueryError = ErrorType; + +/** + * @summary List service orders placed by a user + */ + +export function useGetAdminUserDependentOrders< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminUserDependentOrdersParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminUserDependentOrdersQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary List conversations created by a user + */ +export const getGetAdminUserDependentConversationsUrl = ( + id: number, + params?: GetAdminUserDependentConversationsParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/users/${id}/dependents/conversations?${stringifiedParams}` + : `/api/admin/users/${id}/dependents/conversations`; +}; + +export const getAdminUserDependentConversations = async ( + id: number, + params?: GetAdminUserDependentConversationsParams, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAdminUserDependentConversationsUrl(id, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAdminUserDependentConversationsQueryKey = ( + id: number, + params?: GetAdminUserDependentConversationsParams, +) => { + return [ + `/api/admin/users/${id}/dependents/conversations`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetAdminUserDependentConversationsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminUserDependentConversationsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetAdminUserDependentConversationsQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAdminUserDependentConversations(id, params, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminUserDependentConversationsQueryResult = NonNullable< + Awaited> +>; +export type GetAdminUserDependentConversationsQueryError = + ErrorType; + +/** + * @summary List conversations created by a user + */ + +export function useGetAdminUserDependentConversations< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminUserDependentConversationsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminUserDependentConversationsQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary List messages sent by a user + */ +export const getGetAdminUserDependentMessagesUrl = ( + id: number, + params?: GetAdminUserDependentMessagesParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/admin/users/${id}/dependents/messages?${stringifiedParams}` + : `/api/admin/users/${id}/dependents/messages`; +}; + +export const getAdminUserDependentMessages = async ( + id: number, + params?: GetAdminUserDependentMessagesParams, + options?: RequestInit, +): Promise => { + return customFetch( + getGetAdminUserDependentMessagesUrl(id, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getGetAdminUserDependentMessagesQueryKey = ( + id: number, + params?: GetAdminUserDependentMessagesParams, +) => { + return [ + `/api/admin/users/${id}/dependents/messages`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetAdminUserDependentMessagesQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminUserDependentMessagesParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetAdminUserDependentMessagesQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAdminUserDependentMessages(id, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminUserDependentMessagesQueryResult = NonNullable< + Awaited> +>; +export type GetAdminUserDependentMessagesQueryError = ErrorType; + +/** + * @summary List messages sent by a user + */ + +export function useGetAdminUserDependentMessages< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: GetAdminUserDependentMessagesParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminUserDependentMessagesQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index c0788568..d6772286 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 " 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 " 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 " 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: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 6b1f8bd2..5ea149cc 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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 " 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 " 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(), +});