Task #48: Let admins page through more than the last 100 opens

Added offset/limit pagination to the two admin app-opens drill-in
endpoints so admins can investigate spikes that span more than the
default 100 most-recent opens.

Backend (artifacts/api-server/src/routes/stats.ts):
- New parsePaging() helper validates `limit` (1..200, default 100) and
  `offset` (>=0, default 0); invalid values return 400.
- Both `/stats/admin/app-opens/by-app/:appId` and
  `/stats/admin/app-opens/by-user/:userId` accept the new params, apply
  `.limit(limit).offset(offset)`, and return `limit`, `offset`, and a
  `nextOffset` (number | null) computed from `totalCount`.
- Added a stable secondary sort (`id desc`) so paged results don't
  shuffle when timestamps tie.

Spec & client (lib/api-spec/openapi.yaml + regenerated clients):
- Added `limit`/`offset` query params and `limit`/`offset`/`nextOffset`
  response fields to AdminAppOpensByApp/AdminAppOpensByUser.
- Re-ran `pnpm --filter @workspace/api-spec run codegen`.

Frontend (artifacts/teaboy-os/src/pages/admin.tsx + locales):
- AppOpensDrillIn / UserOpensDrillIn now accumulate extra pages in
  local state and expose a "Load more" button via a shared
  LoadMoreSection footer that also shows "Showing X of Y".
- Extra pages are fetched via the generated `getAdminAppOpensByApp` /
  `getAdminAppOpensByUser` functions; accumulated state resets when
  the appId/userId or stats query params change.
- Added en/ar translations for `loadMore`, `loadMoreError`, `shownOf`.

Tests:
- New artifacts/api-server/tests/admin-app-opens-pagination.test.mjs
  covers happy-path paging for both endpoints, the default page size,
  and 400 responses for invalid limit/offset.
- All 10 api-server tests pass; full workspace typecheck passes.

Replit-Task-Id: b6382efe-765f-4689-8c93-196fee253f63
This commit is contained in:
riyadhafraa
2026-04-21 11:48:40 +00:00
parent ce0819cfaa
commit 216ff65e04
8 changed files with 551 additions and 66 deletions
+61 -7
View File
@@ -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:
@@ -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);
});