diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts index c1686d73..b5c82949 100644 --- a/artifacts/api-server/src/routes/stats.ts +++ b/artifacts/api-server/src/routes/stats.ts @@ -427,7 +427,33 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise< }); }); -const RECENT_OPENS_LIMIT = 100; +const RECENT_OPENS_DEFAULT_LIMIT = 100; +const RECENT_OPENS_MAX_LIMIT = 200; + +function parsePaging(query: { limit?: unknown; offset?: unknown }): + | { limit: number; offset: number } + | { error: string } { + let limit = RECENT_OPENS_DEFAULT_LIMIT; + let offset = 0; + if (query.limit !== undefined) { + const n = Number(query.limit); + if (!Number.isInteger(n) || n <= 0) { + return { error: "Invalid limit: must be a positive integer" }; + } + if (n > RECENT_OPENS_MAX_LIMIT) { + return { error: `Invalid limit: maximum is ${RECENT_OPENS_MAX_LIMIT}` }; + } + limit = n; + } + if (query.offset !== undefined) { + const n = Number(query.offset); + if (!Number.isInteger(n) || n < 0) { + return { error: "Invalid offset: must be a non-negative integer" }; + } + offset = n; + } + return { limit, offset }; +} router.get( "/stats/admin/app-opens/by-app/:appId", @@ -447,6 +473,13 @@ router.get( const { range, rangeStart, rangeEndExclusive, rangeDays, rangeFromStr, rangeToStr } = parsed.window; + const paging = parsePaging(req.query); + if ("error" in paging) { + res.status(400).json({ error: paging.error }); + return; + } + const { limit, offset } = paging; + const [app] = await db .select({ id: appsTable.id, @@ -493,8 +526,12 @@ router.get( sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`, ), ) - .orderBy(sql`${appOpensTable.createdAt} desc`) - .limit(RECENT_OPENS_LIMIT); + .orderBy(sql`${appOpensTable.createdAt} desc`, sql`${appOpensTable.id} desc`) + .limit(limit) + .offset(offset); + + const totalCount = Number(totalRow?.count ?? 0); + const nextOffset = offset + opensRows.length < totalCount ? offset + opensRows.length : null; res.json({ appId: app.id, @@ -507,7 +544,10 @@ router.get( rangeDays, rangeFrom: rangeFromStr, rangeTo: rangeToStr, - totalCount: Number(totalRow?.count ?? 0), + totalCount, + limit, + offset, + nextOffset, opens: opensRows.map((r) => ({ id: r.id, createdAt: @@ -540,6 +580,13 @@ router.get( const { range, rangeStart, rangeEndExclusive, rangeDays, rangeFromStr, rangeToStr } = parsed.window; + const paging = parsePaging(req.query); + if ("error" in paging) { + res.status(400).json({ error: paging.error }); + return; + } + const { limit, offset } = paging; + const [user] = await db .select({ id: usersTable.id, @@ -586,8 +633,12 @@ router.get( sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`, ), ) - .orderBy(sql`${appOpensTable.createdAt} desc`) - .limit(RECENT_OPENS_LIMIT); + .orderBy(sql`${appOpensTable.createdAt} desc`, sql`${appOpensTable.id} desc`) + .limit(limit) + .offset(offset); + + const totalCount = Number(totalRow?.count ?? 0); + const nextOffset = offset + opensRows.length < totalCount ? offset + opensRows.length : null; res.json({ userId: user.id, @@ -599,7 +650,10 @@ router.get( rangeDays, rangeFrom: rangeFromStr, rangeTo: rangeToStr, - totalCount: Number(totalRow?.count ?? 0), + totalCount, + limit, + offset, + nextOffset, opens: opensRows.map((r) => ({ id: r.id, createdAt: diff --git a/artifacts/api-server/tests/admin-app-opens-pagination.test.mjs b/artifacts/api-server/tests/admin-app-opens-pagination.test.mjs new file mode 100644 index 00000000..b6e35be6 --- /dev/null +++ b/artifacts/api-server/tests/admin-app-opens-pagination.test.mjs @@ -0,0 +1,177 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +let adminUserId; +let adminUsername; +let appId; +let sessionCookie; +const insertedOpenIds = []; + +async function loginAndGetCookie(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + assert.ok(setCookie, "expected Set-Cookie header from login"); + const sid = setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); + assert.ok(sid, "expected connect.sid cookie"); + return sid; +} + +before(async () => { + adminUsername = `admin_paging_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 6)}`; + + const userRes = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'Admin Paging Test', 'en', true) RETURNING id`, + [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH], + ); + adminUserId = userRes.rows[0].id; + + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = 'admin'`, + [adminUserId], + ); + + const appRes = await pool.query( + `SELECT id FROM apps WHERE is_active = true ORDER BY id ASC LIMIT 1`, + ); + assert.ok(appRes.rows[0], "no active app found to test against"); + appId = appRes.rows[0].id; + + // Insert 3 opens for this admin user/app within the past few minutes, + // strictly within the 7d range. + const now = Date.now(); + for (let i = 0; i < 3; i++) { + const ts = new Date(now - i * 60 * 1000).toISOString(); + const r = await pool.query( + `INSERT INTO app_opens (user_id, app_id, created_at) VALUES ($1, $2, $3) RETURNING id`, + [adminUserId, appId, ts], + ); + insertedOpenIds.push(r.rows[0].id); + } + + sessionCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); +}); + +after(async () => { + if (insertedOpenIds.length > 0) { + await pool.query(`DELETE FROM app_opens WHERE id = ANY($1::int[])`, [insertedOpenIds]); + } + if (adminUserId !== undefined) { + await pool.query(`DELETE FROM app_opens WHERE user_id = $1`, [adminUserId]); + await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminUserId]); + await pool.query(`DELETE FROM users WHERE id = $1`, [adminUserId]); + } + await pool.end(); +}); + +async function getJson(path) { + const res = await fetch(`${API_BASE}${path}`, { + headers: { Cookie: sessionCookie }, + }); + return { status: res.status, body: await res.json() }; +} + +test("by-app: paginating with limit returns nextOffset until exhausted", async () => { + const page1 = await getJson( + `/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2`, + ); + assert.equal(page1.status, 200); + assert.ok(page1.body.totalCount >= 3, "totalCount should include our inserted opens"); + assert.equal(page1.body.limit, 2); + assert.equal(page1.body.offset, 0); + assert.equal(page1.body.opens.length, 2); + assert.equal(page1.body.nextOffset, 2); + + const page2 = await getJson( + `/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2&offset=2`, + ); + assert.equal(page2.status, 200); + assert.equal(page2.body.offset, 2); + assert.equal(page2.body.opens.length >= 1, true); + + // Walk to the last page; nextOffset eventually becomes null. + let nextOffset = page2.body.nextOffset; + let safety = 50; + while (nextOffset !== null && safety-- > 0) { + const np = await getJson( + `/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2&offset=${nextOffset}`, + ); + assert.equal(np.status, 200); + nextOffset = np.body.nextOffset; + } + assert.equal(nextOffset, null, "should reach end of pages"); +}); + +test("by-user: paginating with limit returns nextOffset until exhausted", async () => { + const page1 = await getJson( + `/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&limit=2`, + ); + assert.equal(page1.status, 200); + assert.equal(page1.body.totalCount, 3); + assert.equal(page1.body.limit, 2); + assert.equal(page1.body.offset, 0); + assert.equal(page1.body.opens.length, 2); + assert.equal(page1.body.nextOffset, 2); + + const page2 = await getJson( + `/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&limit=2&offset=2`, + ); + assert.equal(page2.status, 200); + assert.equal(page2.body.offset, 2); + assert.equal(page2.body.opens.length, 1); + assert.equal(page2.body.nextOffset, null); +}); + +test("by-app: invalid limit returns 400", async () => { + const r = await getJson( + `/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=0`, + ); + assert.equal(r.status, 400); +}); + +test("by-app: limit above max returns 400", async () => { + const r = await getJson( + `/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=999`, + ); + assert.equal(r.status, 400); +}); + +test("by-user: invalid offset returns 400", async () => { + const r = await getJson( + `/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&offset=-1`, + ); + assert.equal(r.status, 400); +}); + +test("by-app: default page size is 100 when no limit specified", async () => { + const r = await getJson( + `/api/stats/admin/app-opens/by-app/${appId}?range=7d`, + ); + assert.equal(r.status, 200); + assert.equal(r.body.limit, 100); + assert.equal(r.body.offset, 0); +}); diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index c56a7e0c..6b4ec589 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -331,7 +331,10 @@ "empty": "لا توجد فتحات في هذا النطاق", "loadError": "تعذّر تحميل آخر الفتحات.", "editApp": "تعديل التطبيق", - "viewUser": "عرض المستخدم" + "viewUser": "عرض المستخدم", + "loadMore": "تحميل المزيد", + "loadMoreError": "تعذّر تحميل المزيد من الفتحات.", + "shownOf": "عرض {{shown}} من {{total}}" } } }, diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index ad6cf18c..6bb6a8a4 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -328,7 +328,10 @@ "empty": "No opens in this range", "loadError": "Couldn't load recent opens.", "editApp": "Edit app", - "viewUser": "View user" + "viewUser": "View user", + "loadMore": "Load more", + "loadMoreError": "Couldn't load more opens.", + "shownOf": "Showing {{shown}} of {{total}}" } } }, diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index d66801ae..bde241d1 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -24,8 +24,10 @@ import { getGetAdminStatsQueryKey, useGetAdminAppOpensByApp, getGetAdminAppOpensByAppQueryKey, + getAdminAppOpensByApp, useGetAdminAppOpensByUser, getGetAdminAppOpensByUserQueryKey, + getAdminAppOpensByUser, useAdminIssueResetLink, } from "@workspace/api-client-react"; import type { @@ -34,6 +36,8 @@ import type { UserProfile, AppSettings, AdminStats, + AdminAppOpensByApp, + AdminAppOpensByUser, GetAdminStatsQueryError, GetAdminStatsParams, } from "@workspace/api-client-react"; @@ -1632,6 +1636,22 @@ function AppOpensDrillIn({ retry: false, }, }); + type ExtraOpen = AdminAppOpensByApp["opens"][number]; + const [extraOpens, setExtraOpens] = useState([]); + const [extraNextOffset, setExtraNextOffset] = useState(null); + const [hasExtras, setHasExtras] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(null); + + const paramsKey = JSON.stringify(statsQueryParams); + useEffect(() => { + setExtraOpens([]); + setExtraNextOffset(null); + setHasExtras(false); + setLoadMoreError(null); + setIsLoadingMore(false); + }, [appId, paramsKey]); + const errorMessage = error ? (error as { message?: string }).message ?? null : null; const appName = data ? (lang === "ar" ? data.nameAr : data.nameEn) : ""; const title = appName @@ -1640,6 +1660,27 @@ function AppOpensDrillIn({ const subtitle = data ? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel }) : rangeLabel; + const opens = data ? [...data.opens, ...extraOpens] : []; + const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null; + + const onLoadMore = async () => { + if (nextOffset === null || isLoadingMore) return; + setIsLoadingMore(true); + setLoadMoreError(null); + try { + const next = await getAdminAppOpensByApp(appId, { + ...statsQueryParams, + offset: nextOffset, + }); + setExtraOpens((prev) => [...prev, ...next.opens]); + setExtraNextOffset(next.nextOffset); + setHasExtras(true); + } catch (e) { + setLoadMoreError((e as { message?: string }).message ?? "Error"); + } finally { + setIsLoadingMore(false); + } + }; return ( {errorMessage} - ) : !data || data.opens.length === 0 ? ( + ) : !data || opens.length === 0 ? (
{t("admin.dashboard.drillIn.empty")}
) : ( -
    - {data.opens.map((o) => { - const display = - (lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username; - const initial = (display || o.username || "?").trim().charAt(0).toUpperCase(); - const avatar = resolveServiceImageUrl(o.avatarUrl); - return ( -
  1. - -
  2. - ); - })} -
+ <> +
    + {opens.map((o) => { + const display = + (lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username; + const initial = (display || o.username || "?").trim().charAt(0).toUpperCase(); + const avatar = resolveServiceImageUrl(o.avatarUrl); + return ( +
  1. + +
  2. + ); + })} +
+ + )}
); } +function LoadMoreSection({ + shownCount, + totalCount, + nextOffset, + isLoadingMore, + loadMoreError, + onLoadMore, +}: { + shownCount: number; + totalCount: number; + nextOffset: number | null; + isLoadingMore: boolean; + loadMoreError: string | null; + onLoadMore: () => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ {t("admin.dashboard.drillIn.shownOf", { shown: shownCount, total: totalCount })} +
+ {nextOffset !== null && ( + + )} + {loadMoreError && ( +
+ {t("admin.dashboard.drillIn.loadMoreError")} +
+ )} +
+ ); +} + function UserOpensDrillIn({ userId, lang, @@ -1726,10 +1819,47 @@ function UserOpensDrillIn({ retry: false, }, }); + type ExtraOpen = AdminAppOpensByUser["opens"][number]; + const [extraOpens, setExtraOpens] = useState([]); + const [extraNextOffset, setExtraNextOffset] = useState(null); + const [hasExtras, setHasExtras] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(null); + + const paramsKey = JSON.stringify(statsQueryParams); + useEffect(() => { + setExtraOpens([]); + setExtraNextOffset(null); + setHasExtras(false); + setLoadMoreError(null); + setIsLoadingMore(false); + }, [userId, paramsKey]); + const errorMessage = error ? (error as { message?: string }).message ?? null : null; const displayName = data ? (lang === "ar" ? data.displayNameAr : data.displayNameEn) ?? data.username : ""; + const opens = data ? [...data.opens, ...extraOpens] : []; + const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null; + + const onLoadMore = async () => { + if (nextOffset === null || isLoadingMore) return; + setIsLoadingMore(true); + setLoadMoreError(null); + try { + const next = await getAdminAppOpensByUser(userId, { + ...statsQueryParams, + offset: nextOffset, + }); + setExtraOpens((prev) => [...prev, ...next.opens]); + setExtraNextOffset(next.nextOffset); + setHasExtras(true); + } catch (e) { + setLoadMoreError((e as { message?: string }).message ?? "Error"); + } finally { + setIsLoadingMore(false); + } + }; const title = displayName ? t("admin.dashboard.drillIn.userTitle", { name: displayName }) : t("admin.dashboard.drillIn.loading"); @@ -1759,37 +1889,47 @@ function UserOpensDrillIn({ {t("admin.dashboard.drillIn.loadError")}
{errorMessage}
- ) : !data || data.opens.length === 0 ? ( + ) : !data || opens.length === 0 ? (
{t("admin.dashboard.drillIn.empty")}
) : ( -
    - {data.opens.map((o) => { - const name = lang === "ar" ? o.nameAr : o.nameEn; - return ( -
  1. - -
  2. - ); - })} -
+ <> +
    + {opens.map((o) => { + const name = lang === "ar" ? o.nameAr : o.nameEn; + return ( +
  1. + +
  2. + ); + })} +
+ + )} ); diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index de23440d..28aa77dd 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -521,6 +521,10 @@ export interface AdminAppOpensByApp { rangeFrom: string; rangeTo: string; totalCount: number; + limit: number; + offset: number; + /** @nullable */ + nextOffset: number | null; opens: AppOpenByAppEntry[]; } @@ -559,6 +563,10 @@ export interface AdminAppOpensByUser { rangeFrom: string; rangeTo: string; totalCount: number; + limit: number; + offset: number; + /** @nullable */ + nextOffset: number | null; opens: AppOpenByUserEntry[]; } @@ -599,6 +607,15 @@ export type GetAdminAppOpensByAppParams = { range?: GetAdminAppOpensByAppRange; from?: string; to?: string; + /** + * @minimum 1 + * @maximum 200 + */ + limit?: number; + /** + * @minimum 0 + */ + offset?: number; }; export type GetAdminAppOpensByAppRange = @@ -615,6 +632,15 @@ export type GetAdminAppOpensByUserParams = { range?: GetAdminAppOpensByUserRange; from?: string; to?: string; + /** + * @minimum 1 + * @maximum 200 + */ + limit?: number; + /** + * @minimum 0 + */ + offset?: number; }; export type GetAdminAppOpensByUserRange = diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 29bb4ca4..c4c3871f 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1078,6 +1078,21 @@ paths: schema: type: string format: date + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + default: 0 responses: "200": description: Recent opens for the app @@ -1128,6 +1143,21 @@ paths: schema: type: string format: date + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + default: 0 responses: "200": description: Recent app opens by the user @@ -2048,6 +2078,12 @@ components: type: string totalCount: type: integer + limit: + type: integer + offset: + type: integer + nextOffset: + type: ["integer", "null"] opens: type: array items: @@ -2064,6 +2100,9 @@ components: - rangeFrom - rangeTo - totalCount + - limit + - offset + - nextOffset - opens AppOpenByUserEntry: @@ -2120,6 +2159,12 @@ components: type: string totalCount: type: integer + limit: + type: integer + offset: + type: integer + nextOffset: + type: ["integer", "null"] opens: type: array items: @@ -2132,6 +2177,9 @@ components: - rangeFrom - rangeTo - totalCount + - limit + - offset + - nextOffset - opens TopUserItem: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index c83f5d23..2b69b85b 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1275,6 +1275,11 @@ export const GetAdminAppOpensByAppParams = zod.object({ }); export const getAdminAppOpensByAppQueryRangeDefault = `7d`; +export const getAdminAppOpensByAppQueryLimitDefault = 100; +export const getAdminAppOpensByAppQueryLimitMax = 200; + +export const getAdminAppOpensByAppQueryOffsetDefault = 0; +export const getAdminAppOpensByAppQueryOffsetMin = 0; export const GetAdminAppOpensByAppQueryParams = zod.object({ range: zod @@ -1282,6 +1287,15 @@ export const GetAdminAppOpensByAppQueryParams = zod.object({ .default(getAdminAppOpensByAppQueryRangeDefault), from: zod.date().optional(), to: zod.date().optional(), + limit: zod.coerce + .number() + .min(1) + .max(getAdminAppOpensByAppQueryLimitMax) + .default(getAdminAppOpensByAppQueryLimitDefault), + offset: zod.coerce + .number() + .min(getAdminAppOpensByAppQueryOffsetMin) + .default(getAdminAppOpensByAppQueryOffsetDefault), }); export const GetAdminAppOpensByAppResponse = zod.object({ @@ -1296,6 +1310,9 @@ export const GetAdminAppOpensByAppResponse = zod.object({ rangeFrom: zod.string(), rangeTo: zod.string(), totalCount: zod.number(), + limit: zod.number(), + offset: zod.number(), + nextOffset: zod.number().nullable(), opens: zod.array( zod.object({ id: zod.number(), @@ -1317,6 +1334,11 @@ export const GetAdminAppOpensByUserParams = zod.object({ }); export const getAdminAppOpensByUserQueryRangeDefault = `7d`; +export const getAdminAppOpensByUserQueryLimitDefault = 100; +export const getAdminAppOpensByUserQueryLimitMax = 200; + +export const getAdminAppOpensByUserQueryOffsetDefault = 0; +export const getAdminAppOpensByUserQueryOffsetMin = 0; export const GetAdminAppOpensByUserQueryParams = zod.object({ range: zod @@ -1324,6 +1346,15 @@ export const GetAdminAppOpensByUserQueryParams = zod.object({ .default(getAdminAppOpensByUserQueryRangeDefault), from: zod.date().optional(), to: zod.date().optional(), + limit: zod.coerce + .number() + .min(1) + .max(getAdminAppOpensByUserQueryLimitMax) + .default(getAdminAppOpensByUserQueryLimitDefault), + offset: zod.coerce + .number() + .min(getAdminAppOpensByUserQueryOffsetMin) + .default(getAdminAppOpensByUserQueryOffsetDefault), }); export const GetAdminAppOpensByUserResponse = zod.object({ @@ -1337,6 +1368,9 @@ export const GetAdminAppOpensByUserResponse = zod.object({ rangeFrom: zod.string(), rangeTo: zod.string(), totalCount: zod.number(), + limit: zod.number(), + offset: zod.number(), + nextOffset: zod.number().nullable(), opens: zod.array( zod.object({ id: zod.number(),