diff --git a/packages/server/modules/core/domain/users/operations.ts b/packages/server/modules/core/domain/users/operations.ts index 8c6c87e0b..178e6c55a 100644 --- a/packages/server/modules/core/domain/users/operations.ts +++ b/packages/server/modules/core/domain/users/operations.ts @@ -4,7 +4,7 @@ import { UserWithOptionalRole } from '@/modules/core/domain/users/types' import { UserUpdateInput } from '@/modules/core/graph/generated/graphql' -import { ServerAclRecord } from '@/modules/core/helpers/types' +import { ServerAclRecord, UserWithRole } from '@/modules/core/helpers/types' import { Nullable, NullableKeysToOptional, ServerRoles } from '@speckle/shared' export type GetUserParams = Partial<{ @@ -55,6 +55,20 @@ export type CountAdminUsers = () => Promise export type IsLastAdminUser = (userId: string) => Promise +type UserQuery = { + query: string | null + role?: ServerRoles | null +} + +export type ListPaginatedUsersPage = ( + params: { + limit: number + cursor?: Date | null + } & UserQuery +) => Promise + +export type CountUsers = (params: UserQuery) => Promise + export type StoreUserAcl = (params: { acl: ServerAclRecord }) => Promise @@ -137,3 +151,16 @@ export type SearchLimitedUsers = ( users: LimitedUser[] cursor: Nullable }> + +type AdminUserListArgs = { + cursor: string | null + query: string | null + limit: number + role: ServerRoles | null +} + +export type AdminUserList = (args: AdminUserListArgs) => Promise<{ + totalCount: number + items: UserWithRole[] + cursor: string | null +}> diff --git a/packages/server/modules/core/graph/resolvers/admin.ts b/packages/server/modules/core/graph/resolvers/admin.ts index 9ee026d94..baeb3a299 100644 --- a/packages/server/modules/core/graph/resolvers/admin.ts +++ b/packages/server/modules/core/graph/resolvers/admin.ts @@ -2,16 +2,22 @@ import { db } from '@/db/knex' import { Resolvers } from '@/modules/core/graph/generated/graphql' import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes' import { toProjectIdWhitelist } from '@/modules/core/helpers/token' +import { countUsersFactory, listUsersFactory } from '@/modules/core/repositories/users' import { adminInviteList, adminProjectList, - adminUserList + adminUserListFactory } from '@/modules/core/services/admin' import { getTotalStreamCountFactory, getTotalUserCountFactory } from '@/modules/stats/repositories' +const adminUserList = adminUserListFactory({ + listUsers: listUsersFactory({ db }), + countUsers: countUsersFactory({ db }) +}) + export = { Query: { admin: () => ({}) diff --git a/packages/server/modules/core/repositories/users.ts b/packages/server/modules/core/repositories/users.ts index e7a153170..95f99238f 100644 --- a/packages/server/modules/core/repositories/users.ts +++ b/packages/server/modules/core/repositories/users.ts @@ -12,6 +12,7 @@ import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/em import { UserWithOptionalRole } from '@/modules/core/domain/users/types' import { CountAdminUsers, + CountUsers, DeleteUserRecord, GetUser, GetUserByEmail, @@ -23,6 +24,7 @@ import { LegacyGetPaginatedUsersCount, LegacyGetUser, LegacyGetUserByEmail, + ListPaginatedUsersPage, SearchLimitedUsers, StoreUser, StoreUserAcl, @@ -97,57 +99,53 @@ export const getUsersFactory = return (await q).map((u) => (skipClean ? u : sanitizeUserRecord(u))) } -type UserQuery = { - query: string | null - role?: ServerRoles | null -} - /** * List users */ -export async function listUsers({ - limit, - cursor, - query, - role -}: { - limit: number - cursor?: Date | null -} & UserQuery): Promise { - const sanitizedLimit = clamp(limit, 1, 200) +export const listUsersFactory = + (deps: { db: Knex }): ListPaginatedUsersPage => + async ({ limit, cursor, query, role }) => { + const sanitizedLimit = clamp(limit, 1, 200) - const userCols = omit(Users.col, ['email', 'verified']) - const q = Users.knex() - .orderBy(Users.col.createdAt, 'desc') - .limit(sanitizedLimit) - .columns([ - ...Object.values(userCols), - // Getting first role from grouped results - knex.raw(`(array_agg("server_acl"."role"))[1] as role`), - knex.raw(`(array_agg("user_emails"."email"))[1] as email`), - knex.raw(`(array_agg("user_emails"."verified"))[1] as verified`) - ]) - .leftJoin(ServerAcl.name, ServerAcl.col.userId, Users.col.id) - .leftJoin(UserEmails.name, UserEmails.col.userId, Users.col.id) - .where({ [UserEmails.col.primary]: true }) - .groupBy(Users.col.id) - if (cursor) q.where(Users.col.createdAt, '<', cursor) - const users: UserWithRole[] = await getUsersBaseQuery(q, { searchQuery: query, role }) - return users.map((u) => sanitizeUserRecord(u)) -} + const userCols = omit(Users.col, ['email', 'verified']) + const q = tables + .users(deps.db) + .orderBy(Users.col.createdAt, 'desc') + .limit(sanitizedLimit) + .columns([ + ...Object.values(userCols), + // Getting first role from grouped results + knex.raw(`(array_agg("server_acl"."role"))[1] as role`), + knex.raw(`(array_agg("user_emails"."email"))[1] as email`), + knex.raw(`(array_agg("user_emails"."verified"))[1] as verified`) + ]) + .leftJoin(ServerAcl.name, ServerAcl.col.userId, Users.col.id) + .leftJoin(UserEmails.name, UserEmails.col.userId, Users.col.id) + .where({ [UserEmails.col.primary]: true }) + .groupBy(Users.col.id) + if (cursor) q.where(Users.col.createdAt, '<', cursor) + const users: UserWithRole[] = await getUsersBaseQuery(q, { + searchQuery: query, + role + }) + return users.map((u) => sanitizeUserRecord(u)) + } -export async function countUsers(args: UserQuery): Promise { - const q = Users.knex() - .leftJoin(ServerAcl.name, ServerAcl.col.userId, Users.col.id) - .leftJoin(UserEmails.name, UserEmails.col.userId, Users.col.id) - .countDistinct(Users.col.id) +export const countUsersFactory = + (deps: { db: Knex }): CountUsers => + async (args) => { + const q = tables + .users(deps.db) + .leftJoin(ServerAcl.name, ServerAcl.col.userId, Users.col.id) + .leftJoin(UserEmails.name, UserEmails.col.userId, Users.col.id) + .countDistinct(Users.col.id) - const result = await getUsersBaseQuery(q, { - searchQuery: args.query, - role: args.role - }) - return parseInt(result[0]['count']) -} + const result = await getUsersBaseQuery(q, { + searchQuery: args.query, + role: args.role + }) + return parseInt(result[0]['count']) + } /** * Get user by ID diff --git a/packages/server/modules/core/services/admin.ts b/packages/server/modules/core/services/admin.ts index 2512935f1..ce62f95bf 100644 --- a/packages/server/modules/core/services/admin.ts +++ b/packages/server/modules/core/services/admin.ts @@ -1,8 +1,12 @@ import db from '@/db/knex' +import { + AdminUserList, + CountUsers, + ListPaginatedUsersPage +} from '@/modules/core/domain/users/operations' import { ServerInviteGraphQLReturnType } from '@/modules/core/helpers/graphTypes' -import { StreamRecord, UserRecord } from '@/modules/core/helpers/types' +import { StreamRecord } from '@/modules/core/helpers/types' import { legacyGetStreamsFactory } from '@/modules/core/repositories/streams' -import { listUsers, countUsers } from '@/modules/core/repositories/users' import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' import { countServerInvitesFactory, @@ -34,37 +38,42 @@ type AdminUserListArgs = CollectionQueryArgs & { role: ServerRoles | null } -export class CursorParsingError extends BaseError { +class CursorParsingError extends BaseError { static defaultMessage = 'Invalid cursor provided' static code = 'INVALID_CURSOR_VALUE' static statusCode = 400 } -export const parseCursorToDate = (cursor: string): Date => { +const parseCursorToDate = (cursor: string): Date => { const timestamp = Date.parse(Buffer.from(cursor, 'base64').toString('utf-8')) if (isNaN(timestamp)) throw new CursorParsingError() return new Date(timestamp) } -export const convertDateToCursor = (date: Date): string => +const convertDateToCursor = (date: Date): string => Buffer.from(date.toISOString()).toString('base64') -export const adminUserList = async ( - args: AdminUserListArgs -): Promise> => { - const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null - const [items, totalCount] = await Promise.all([ - listUsers({ - role: args.role, - cursor: parsedCursor, - limit: args.limit, - query: args.query ?? null - }), - countUsers(args) - ]) - const cursor = items.length ? convertDateToCursor(items.slice(-1)[0].createdAt) : null - return { totalCount, items, cursor } -} +export const adminUserListFactory = + (deps: { + listUsers: ListPaginatedUsersPage + countUsers: CountUsers + }): AdminUserList => + async (args: AdminUserListArgs) => { + const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null + const [items, totalCount] = await Promise.all([ + deps.listUsers({ + role: args.role, + cursor: parsedCursor, + limit: args.limit, + query: args.query ?? null + }), + deps.countUsers(args) + ]) + const cursor = items.length + ? convertDateToCursor(items.slice(-1)[0].createdAt) + : null + return { totalCount, items, cursor } + } type AdminProjectListArgs = HasCursor & { query: string | null diff --git a/packages/server/modules/core/tests/integration/findUsers.spec.ts b/packages/server/modules/core/tests/integration/findUsers.spec.ts index 252f438cd..7eb594d9f 100644 --- a/packages/server/modules/core/tests/integration/findUsers.spec.ts +++ b/packages/server/modules/core/tests/integration/findUsers.spec.ts @@ -16,7 +16,7 @@ import { getUserByEmailFactory, getUserFactory, getUsersFactory, - listUsers, + listUsersFactory, storeUserAclFactory, storeUserFactory } from '@/modules/core/repositories/users' @@ -63,6 +63,7 @@ const createUser = createUserFactory({ usersEventsEmitter: UsersEmitter.emit }) const getUserByEmail = getUserByEmailFactory({ db }) +const listUsers = listUsersFactory({ db }) describe('Find users @core', () => { describe('getUsers', () => { diff --git a/packages/server/modules/core/tests/integration/userEmails.spec.ts b/packages/server/modules/core/tests/integration/userEmails.spec.ts index cb8533750..5bf303694 100644 --- a/packages/server/modules/core/tests/integration/userEmails.spec.ts +++ b/packages/server/modules/core/tests/integration/userEmails.spec.ts @@ -8,7 +8,7 @@ import { legacyGetPaginatedUsersCountFactory, legacyGetPaginatedUsersFactory, legacyGetUserByEmailFactory, - listUsers, + listUsersFactory, markUserAsVerified, storeUserAclFactory, storeUserFactory @@ -83,6 +83,7 @@ const createUser = createUserFactory({ }) const getUserByEmail = getUserByEmailFactory({ db }) const legacyGetUserByEmail = legacyGetUserByEmailFactory({ db }) +const listUsers = listUsersFactory({ db }) describe('Core @user-emails', () => { before(async () => {