Просмотр исходного кода

RHL-043 feat(api): add sortable admin users listing with cursor safety

Code_Uwe 1 месяц назад
Родитель
Сommit
31569ec61d
2 измененных файлов с 403 добавлено и 36 удалено
  1. 147 36
      app/api/admin/users/route.js
  2. 256 0
      app/api/admin/users/route.test.js

+ 147 - 36
app/api/admin/users/route.js

@@ -5,6 +5,11 @@ import { getDb } from "@/lib/db";
 import { getSession } from "@/lib/auth/session";
 import { requireUserManagement } from "@/lib/auth/permissions";
 import { validateNewPassword } from "@/lib/auth/passwordPolicy";
+import {
+	ADMIN_USERS_SORT,
+	normalizeAdminUsersSortMode,
+	sortAdminUsers,
+} from "@/lib/frontend/admin/users/usersSorting";
 import {
 	withErrorHandling,
 	json,
@@ -89,25 +94,56 @@ function encodeCursor(payload) {
 		});
 	}
 
-	const v = payload.v ?? 1;
-	const lastId = payload.lastId;
+	const v = payload.v;
+	if (v === 1) {
+		const lastId = payload.lastId;
+		if (typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+		return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
+			"base64url",
+		);
+	}
 
-	if (v !== 1 || typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
-		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
-			field: "cursor",
-		});
+	if (v === 2) {
+		const sort = normalizeAdminUsersSortMode(payload.sort);
+		const offset = payload.offset;
+
+		if (sort === null || sort === ADMIN_USERS_SORT.DEFAULT) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+		if (!Number.isInteger(offset) || offset < 0) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		return Buffer.from(JSON.stringify({ v: 2, sort, offset }), "utf8").toString(
+			"base64url",
+		);
 	}
 
-	return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
-		"base64url",
-	);
+	throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+		field: "cursor",
+	});
 }
 
-function decodeCursorOrThrow(raw) {
+function decodeCursorOrThrow(raw, expectedSort) {
 	if (raw === null || raw === undefined || String(raw).trim() === "") {
 		return null;
 	}
 
+	const sort = normalizeAdminUsersSortMode(expectedSort);
+	if (sort === null) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+			field: "cursor",
+		});
+	}
+
 	const s = String(raw).trim();
 
 	let decoded;
@@ -129,19 +165,56 @@ function decodeCursorOrThrow(raw) {
 	}
 
 	if (!isPlainObject(parsed) || parsed.v !== 1) {
-		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
-			field: "cursor",
-		});
+		// continue with shape-specific validation below
 	}
 
-	const lastId = parsed.lastId;
-	if (typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
-		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
-			field: "cursor",
-		});
+	if (parsed?.v === 1) {
+		const lastId = parsed.lastId;
+		const parsedSort = normalizeAdminUsersSortMode(parsed.sort);
+		const effectiveSort = parsedSort ?? ADMIN_USERS_SORT.DEFAULT;
+
+		if (sort !== effectiveSort) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		if (sort !== ADMIN_USERS_SORT.DEFAULT) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		if (typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		return { lastId };
 	}
 
-	return lastId;
+	if (parsed?.v === 2) {
+		const parsedSort = normalizeAdminUsersSortMode(parsed.sort);
+		const offset = parsed.offset;
+
+		if (parsedSort === null || parsedSort !== sort) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+		if (!Number.isInteger(offset) || offset < 0) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		return { offset };
+	}
+
+	throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+		field: "cursor",
+	});
 }
 
 function toIsoOrNull(value) {
@@ -218,8 +291,18 @@ export const GET = withErrorHandling(
 				? branchIdRaw.trim()
 				: null;
 
+		const sortRaw = searchParams.get("sort");
+		const sort = normalizeAdminUsersSortMode(sortRaw);
+		if (sort === null) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid sort", {
+				field: "sort",
+				value: sortRaw,
+				allowed: Object.values(ADMIN_USERS_SORT),
+			});
+		}
+
 		const limit = parseLimitOrThrow(searchParams.get("limit"));
-		const cursor = decodeCursorOrThrow(searchParams.get("cursor"));
+		const cursor = decodeCursorOrThrow(searchParams.get("cursor"), sort);
 
 		if (role && !ALLOWED_ROLES.has(role)) {
 			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
@@ -246,31 +329,59 @@ export const GET = withErrorHandling(
 
 		if (role) filter.role = role;
 		if (branchId) filter.branchId = branchId;
-		if (cursor) filter._id = { $lt: cursor };
 
 		await getDb();
 
-		const docs = await User.find(filter)
-			.sort({ _id: -1 })
-			.limit(limit + 1)
-			.select(
-				"_id username email role branchId mustChangePassword createdAt updatedAt",
-			)
-			.exec();
-
-		const list = Array.isArray(docs) ? docs : [];
+		let docs = [];
+		let nextCursor = null;
+
+		if (sort === ADMIN_USERS_SORT.DEFAULT) {
+			if (cursor?.lastId) filter._id = { $lt: cursor.lastId };
+
+			const rawDocs = await User.find(filter)
+				.sort({ _id: -1 })
+				.limit(limit + 1)
+				.select(
+					"_id username email role branchId mustChangePassword createdAt updatedAt",
+				)
+				.exec();
+
+			const list = Array.isArray(rawDocs) ? rawDocs : [];
+			const hasMore = list.length > limit;
+			docs = hasMore ? list.slice(0, limit) : list;
+
+			nextCursor =
+				hasMore && docs.length > 0
+					? encodeCursor({ v: 1, lastId: String(docs[docs.length - 1]._id) })
+					: null;
+		} else {
+			const rawDocs = await User.find(filter)
+				.select(
+					"_id username email role branchId mustChangePassword createdAt updatedAt",
+				)
+				.exec();
+
+			const sorted = sortAdminUsers(
+				Array.isArray(rawDocs) ? rawDocs : [],
+				sort,
+			);
 
-		const hasMore = list.length > limit;
-		const page = hasMore ? list.slice(0, limit) : list;
+			const offset = cursor?.offset ?? 0;
+			docs = sorted.slice(offset, offset + limit);
+			const hasMore = offset + docs.length < sorted.length;
 
-		const nextCursor =
-			hasMore && page.length > 0
-				? encodeCursor({ v: 1, lastId: String(page[page.length - 1]._id) })
+			nextCursor = hasMore
+				? encodeCursor({
+						v: 2,
+						sort,
+						offset: offset + docs.length,
+					})
 				: null;
+		}
 
 		return json(
 			{
-				items: page.map(toSafeUser),
+				items: docs.map(toSafeUser),
 				nextCursor,
 			},
 			200,

+ 256 - 0
app/api/admin/users/route.test.js

@@ -49,6 +49,12 @@ function buildCursor(lastId) {
 	);
 }
 
+function buildOffsetCursor(sort, offset) {
+	return Buffer.from(JSON.stringify({ v: 2, sort, offset }), "utf8").toString(
+		"base64url",
+	);
+}
+
 function createRequestStub(body) {
 	return {
 		async json() {
@@ -178,6 +184,256 @@ describe("GET /api/admin/users", () => {
 
 		expect(body.nextCursor).toBe(buildCursor("507f1f77bcf86cd799439012"));
 	});
+
+	it("returns 400 for invalid sort parameter", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/admin/users?sort=unknown"),
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid sort",
+				code: "VALIDATION_INVALID_FIELD",
+				details: {
+					field: "sort",
+					value: "unknown",
+					allowed: ["default", "role_rights", "branch_asc"],
+				},
+			},
+		});
+	});
+
+	it("returns sorted users for sort=role_rights with stable nextCursor", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const docs = [
+			{
+				_id: "507f1f77bcf86cd799439011",
+				username: "branchb",
+				email: "branchb@example.com",
+				role: "branch",
+				branchId: "NL10",
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439012",
+				username: "admin",
+				email: "admin@example.com",
+				role: "admin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439013",
+				username: "dev",
+				email: "dev@example.com",
+				role: "dev",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439014",
+				username: "super",
+				email: "super@example.com",
+				role: "superadmin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+		];
+
+		const chain = {
+			select: vi.fn().mockReturnThis(),
+			exec: vi.fn().mockResolvedValue(docs),
+		};
+
+		User.find.mockReturnValue(chain);
+
+		const res = await GET(
+			new Request("http://localhost/api/admin/users?sort=role_rights&limit=2"),
+		);
+
+		expect(res.status).toBe(200);
+		expect(chain.select).toHaveBeenCalledTimes(1);
+		expect(chain.exec).toHaveBeenCalledTimes(1);
+
+		const body = await res.json();
+		expect(body.items.map((x) => x.role)).toEqual(["superadmin", "dev"]);
+		expect(body.nextCursor).toBe(buildOffsetCursor("role_rights", 2));
+	});
+
+	it("returns sorted users for sort=branch_asc with null branches at the end", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const docs = [
+			{
+				_id: "507f1f77bcf86cd799439011",
+				username: "branch10",
+				email: "branch10@example.com",
+				role: "branch",
+				branchId: "NL10",
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439012",
+				username: "admin",
+				email: "admin@example.com",
+				role: "admin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439013",
+				username: "branch2",
+				email: "branch2@example.com",
+				role: "branch",
+				branchId: "NL2",
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439014",
+				username: "branch1",
+				email: "branch1@example.com",
+				role: "branch",
+				branchId: "NL01",
+				mustChangePassword: false,
+			},
+		];
+
+		const chain = {
+			select: vi.fn().mockReturnThis(),
+			exec: vi.fn().mockResolvedValue(docs),
+		};
+
+		User.find.mockReturnValue(chain);
+
+		const res = await GET(
+			new Request("http://localhost/api/admin/users?sort=branch_asc&limit=10"),
+		);
+
+		expect(res.status).toBe(200);
+		const body = await res.json();
+		expect(body.items.map((x) => x.branchId)).toEqual([
+			"NL01",
+			"NL2",
+			"NL10",
+			null,
+		]);
+		expect(body.nextCursor).toBe(null);
+	});
+
+	it("returns 400 when cursor sort context does not match sort parameter", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const cursor = buildOffsetCursor("role_rights", 2);
+		const res = await GET(
+			new Request(
+				`http://localhost/api/admin/users?sort=branch_asc&cursor=${encodeURIComponent(cursor)}`,
+			),
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid cursor",
+				code: "VALIDATION_INVALID_FIELD",
+				details: { field: "cursor" },
+			},
+		});
+	});
+
+	it("keeps pagination stable within the same custom sort mode", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const docs = [
+			{
+				_id: "507f1f77bcf86cd799439011",
+				username: "branchb",
+				email: "branchb@example.com",
+				role: "branch",
+				branchId: "NL10",
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439012",
+				username: "admin",
+				email: "admin@example.com",
+				role: "admin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439013",
+				username: "dev",
+				email: "dev@example.com",
+				role: "dev",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439014",
+				username: "super",
+				email: "super@example.com",
+				role: "superadmin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+		];
+
+		const chain = {
+			select: vi.fn().mockReturnThis(),
+			exec: vi.fn().mockResolvedValue(docs),
+		};
+
+		User.find.mockReturnValue(chain);
+
+		const firstRes = await GET(
+			new Request("http://localhost/api/admin/users?sort=role_rights&limit=2"),
+		);
+		expect(firstRes.status).toBe(200);
+
+		const firstBody = await firstRes.json();
+		expect(firstBody.items.map((x) => x.role)).toEqual(["superadmin", "dev"]);
+		expect(firstBody.nextCursor).toBe(buildOffsetCursor("role_rights", 2));
+
+		const secondRes = await GET(
+			new Request(
+				`http://localhost/api/admin/users?sort=role_rights&limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`,
+			),
+		);
+		expect(secondRes.status).toBe(200);
+
+		const secondBody = await secondRes.json();
+		expect(secondBody.items.map((x) => x.role)).toEqual(["admin", "branch"]);
+		expect(secondBody.nextCursor).toBe(null);
+	});
 });
 
 describe("POST /api/admin/users", () => {