setSearchTerm(e.target.value)}
/>
+
+ {isFetching && totalFetched === 0
+ ? t("loading")
+ : `${t("showing_x_of_y", { x: totalFetched, y: totalRowCount })}`}
+
+
fetchMoreOnBottomReached()}
+ onScroll={() => fetchMoreOnBottomReached(tableContainerRef.current)}
style={{
height: "calc(100vh - 30vh)",
overflow: "auto",
diff --git a/packages/features/users/repositories/UserRepository.test.ts b/packages/features/users/repositories/UserRepository.test.ts
index 44e7001c96..eb870190b1 100644
--- a/packages/features/users/repositories/UserRepository.test.ts
+++ b/packages/features/users/repositories/UserRepository.test.ts
@@ -112,3 +112,158 @@ describe("UserRepository", () => {
});
});
});
+describe("listUsers", () => {
+ test("Should return all users matching search term with default pagination", async () => {
+ await new UserRepository(prismock).create({
+ username: "alice",
+ email: "alice@example.com",
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: false,
+ });
+ await new UserRepository(prismock).create({
+ username: "bob",
+ email: "bob@example.com",
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: false,
+ });
+ const { users, total } = await new UserRepository(prismock).listUsers({
+ searchTerm: null,
+ cursor: null,
+ limit: 10,
+ });
+
+ expect(users).toHaveLength(2);
+ expect(total).toEqual(2);
+ });
+ test("Should filter users by searchTerm matching username (case-insensitive)", async () => {
+ await new UserRepository(prismock).create({
+ username: "alice",
+ email: "alice@example.com",
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: false,
+ });
+ await new UserRepository(prismock).create({
+ username: "bob",
+ email: "bob@example.com",
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: false,
+ });
+
+ const { users, total } = await new UserRepository(prismock).listUsers({
+ searchTerm: "BOB",
+ cursor: null,
+ limit: 10,
+ });
+
+ expect(users).toHaveLength(1);
+ expect(users[0]).toEqual(
+ expect.objectContaining({
+ username: "bob",
+ })
+ );
+ const result = await new UserRepository(prismock).listUsers({
+ searchTerm: "ALiCE",
+ cursor: null,
+ limit: 10,
+ });
+
+ expect(result.users).toHaveLength(1);
+ expect(result.users[0]).toEqual(
+ expect.objectContaining({
+ username: "alice",
+ })
+ );
+ });
+
+ test("Should include both locked and unlocked users", async () => {
+ await new UserRepository(prismock).create({
+ username: "locked-user",
+ email: "locked@example.com",
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: true,
+ });
+ await new UserRepository(prismock).create({
+ username: "unlocked-user",
+ email: "unlocked@example.com",
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: false,
+ });
+
+ const { users, total } = await new UserRepository(prismock).listUsers({
+ searchTerm: null,
+ cursor: null,
+ limit: 10,
+ });
+
+ expect(total).toEqual(2);
+ expect(users.map((u) => u.locked).sort()).toEqual([false, true]);
+ });
+
+ test("Should respect limit by returning limit + 1 rows for cursor calculation", async () => {
+ for (let i = 0; i < 5; i++) {
+ await new UserRepository(prismock).create({
+ username: `user${i}`,
+ email: `user${i}@example.com`,
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: false,
+ });
+ }
+
+ const { users, total } = await new UserRepository(prismock).listUsers({
+ searchTerm: null,
+ cursor: null,
+ limit: 3,
+ });
+ // take = limit + 1, so up to 4 rows come back to let the caller detect "has more"
+ expect(users.length).toBeLessThanOrEqual(4);
+ expect(total).toEqual(5);
+ });
+
+ test("Should return all users when limit is not passed (no cap)", async () => {
+ for (let i = 0; i < 5; i++) {
+ await new UserRepository(prismock).create({
+ username: `nolimituser${i}`,
+ email: `nolimituser${i}@example.com`,
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: false,
+ });
+ }
+
+ const { users, total } = await new UserRepository(prismock).listUsers({
+ searchTerm: null,
+ cursor: null,
+ limit: undefined,
+ });
+
+ expect(users).toHaveLength(5);
+ expect(total).toEqual(5);
+ });
+
+ test("Should return empty array when no users match searchTerm", async () => {
+ await new UserRepository(prismock).create({
+ username: "alice",
+ email: "alice@example.com",
+ organizationId: null,
+ creationSource: CreationSource.WEBAPP,
+ locked: false,
+ });
+
+ const { users, total } = await new UserRepository(prismock).listUsers({
+
+ searchTerm: "nonexistent-term-xyz",
+ cursor: null,
+ limit: 10,
+ });
+
+ expect(users).toEqual([]);
+ expect(total).toEqual(0);
+ });
+});
\ No newline at end of file
diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts
index d5c26676d8..9074c3650f 100644
--- a/packages/features/users/repositories/UserRepository.ts
+++ b/packages/features/users/repositories/UserRepository.ts
@@ -1587,4 +1587,79 @@ export class UserRepository {
select: { id: true },
});
}
-}
+ async listUsers({
+ searchTerm,
+ cursor,
+ limit,
+ }: {
+ searchTerm?: string | null;
+ cursor: number | null | undefined;
+ limit?: number | null;
+ }) {
+ const bothLockedAndUnlockedWhere: Prisma.UserWhereInput = {
+ OR: [{ locked: false }, { locked: true }],
+ };
+ const trimmedSearchTerm = searchTerm?.trim();
+ const searchFilters: Prisma.UserWhereInput = trimmedSearchTerm
+ ? {
+ AND: [
+ // To bypass the excludeLockedUsersExtension
+ bothLockedAndUnlockedWhere,
+ {
+ OR: [
+ { email: { contains: trimmedSearchTerm, mode: "insensitive" } },
+ { username: { contains: trimmedSearchTerm, mode: "insensitive" } },
+ {
+ profiles: {
+ some: {
+ username: { contains: trimmedSearchTerm, mode: "insensitive" },
+ },
+ },
+ },
+ ],
+ },
+ ],
+ }
+ // To bypass the excludeLockedUsersExtension
+ : bothLockedAndUnlockedWhere;
+
+ const hasLimit = limit !== undefined && limit !== null;
+ const take = hasLimit ? limit + 1 : undefined; // +1 lets us detect "has more" for the cursor
+
+ const users = await this.prismaClient.user.findMany({
+ cursor: cursor ? { id: cursor } : undefined,
+ skip: cursor ? 1 : 0,
+ ...(take !== undefined ? { take } : {}),
+ where: searchFilters,
+ orderBy: {
+ id: "asc",
+ },
+ select: {
+ id: true,
+ locked: true,
+ email: true,
+ username: true,
+ name: true,
+ timeZone: true,
+ role: true,
+ profiles: {
+ select: {
+ username: true,
+ },
+ },
+ },
+ });
+
+ if (!hasLimit) {
+ return { users, nextCursor: undefined, total: users.length };
+ }
+
+ const total = await this.prismaClient.user.count({
+ where: searchFilters,
+ });
+ const hasMore = users.length > limit;
+ const items = hasMore ? users.slice(0, limit) : users;
+ const nextCursor = hasMore ? items[items.length - 1].id : undefined;
+ return { users: items, nextCursor, total };
+ }
+}
\ No newline at end of file
diff --git a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts
index 2529b942cd..3124530c80 100644
--- a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts
+++ b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts
@@ -1,5 +1,4 @@
-import { prisma } from "@calcom/prisma";
-import type { Prisma } from "@calcom/prisma/client";
+import { getUserRepository } from "@calcom/features/di/containers/UserRepository";
import type { TrpcSessionUser } from "../../../types";
import type { TListMembersSchema } from "./listPaginated.schema";
@@ -10,83 +9,23 @@ type GetOptions = {
};
input: TListMembersSchema;
};
-
const listPaginatedHandler = async ({ input }: GetOptions) => {
+ const userRepository = getUserRepository();
+
const { cursor, limit, searchTerm } = input;
- const getTotalUsers = await prisma.user.count();
-
- let searchFilters: Prisma.UserWhereInput = {};
- const bothLockedAndUnlockedWhere = { OR: [{ locked: false }, { locked: true }] };
-
- if (searchTerm) {
- searchFilters = {
- // To bypass the excludeLockedUsersExtension
- AND: bothLockedAndUnlockedWhere,
- OR: [
- {
- email: {
- contains: searchTerm.toLowerCase(),
- },
- },
- {
- username: {
- contains: searchTerm.toLocaleLowerCase(),
- },
- },
- {
- profiles: {
- some: {
- username: {
- contains: searchTerm.toLowerCase(),
- },
- },
- },
- },
- ],
- };
- } else {
- // To bypass the excludeLockedUsersExtension
- searchFilters = bothLockedAndUnlockedWhere;
- }
-
- const users = await prisma.user.findMany({
- cursor: cursor ? { id: cursor } : undefined,
- take: limit + 1, // We take +1 as itll be used for the next cursor
- where: {
- ...searchFilters,
- },
- orderBy: {
- id: "asc",
- },
- select: {
- id: true,
- locked: true,
- email: true,
- username: true,
- name: true,
- timeZone: true,
- role: true,
- profiles: {
- select: {
- username: true,
- },
- },
- },
- });
-
- let nextCursor: typeof cursor | undefined = undefined;
- if (users && users.length > limit) {
- const nextItem = users.pop();
- nextCursor = nextItem?.id;
- }
+ const { users, total, nextCursor } = await userRepository.listUsers({
+ searchTerm,
+ limit,
+ cursor
+ })
return {
- rows: users || [],
+ rows: users,
nextCursor,
meta: {
- totalRowCount: getTotalUsers || 0,
- },
+ totalRowCount: total
+ }
};
};