Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(users): bulk lookup users by email #3720

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/frontend-2/lib/common/generated/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,12 @@ export type BranchUpdateInput = {
streamId: Scalars['String']['input'];
};

export type BulkUsersRetrievalInput = {
cursor?: InputMaybe<Scalars['String']['input']>;
emails: Array<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
};

export type CancelCheckoutSessionInput = {
sessionId: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
Expand Down Expand Up @@ -2578,6 +2584,8 @@ export type Query = {
userSearch: UserSearchResultCollection;
/** Look up server users */
users: UserSearchResultCollection;
/** Look up server users with a collection of emails */
usersByEmail: UserSearchResultCollection;
/** Validates the slug, to make sure it contains only valid characters and its not taken. */
validateWorkspaceSlug: Scalars['Boolean']['output'];
workspace: Workspace;
Expand Down Expand Up @@ -2724,6 +2732,11 @@ export type QueryUsersArgs = {
};


export type QueryUsersByEmailArgs = {
input: BulkUsersRetrievalInput;
};


export type QueryValidateWorkspaceSlugArgs = {
slug: Scalars['String']['input'];
};
Expand Down Expand Up @@ -7578,6 +7591,7 @@ export type QueryFieldArgs = {
userPwdStrength: QueryUserPwdStrengthArgs,
userSearch: QueryUserSearchArgs,
users: QueryUsersArgs,
usersByEmail: QueryUsersByEmailArgs,
validateWorkspaceSlug: QueryValidateWorkspaceSlugArgs,
workspace: QueryWorkspaceArgs,
workspaceBySlug: QueryWorkspaceBySlugArgs,
Expand Down
13 changes: 13 additions & 0 deletions packages/server/assets/core/typedefs/user.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ extend type Query {
@hasServerRole(role: SERVER_GUEST)
@hasScopes(scopes: ["users:read", "profile:read"])

"""
Look up server users with a collection of emails
"""
usersByEmail(input: BulkUsersRetrievalInput!): UserSearchResultCollection!
@hasServerRole(role: SERVER_GUEST)
@hasScopes(scopes: ["users:read", "profile:read"])

"""
Validate password strength
"""
Expand Down Expand Up @@ -80,6 +87,12 @@ input UsersRetrievalInput {
projectId: String
}

input BulkUsersRetrievalInput {
emails: [String!]!
cursor: String
limit: Int
}

type PasswordStrengthCheckResults {
"""
Integer from 0-4 (useful for implementing a strength bar):
Expand Down
12 changes: 12 additions & 0 deletions packages/server/modules/core/domain/users/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ export type LookupUsers = (filter: {
cursor: Nullable<string>
}>

export type BulkLookupUsers = (filter: {
emails: string[]
/**
* Defaults to 10
*/
limit?: MaybeNullOrUndefined<number>
cursor?: MaybeNullOrUndefined<string>
}) => Promise<{
users: User[]
cursor: Nullable<string>
}>

type AdminUserListArgs = {
cursor: string | null
query: string | null
Expand Down
16 changes: 16 additions & 0 deletions packages/server/modules/core/graph/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,12 @@ export type BranchUpdateInput = {
streamId: Scalars['String']['input'];
};

export type BulkUsersRetrievalInput = {
cursor?: InputMaybe<Scalars['String']['input']>;
emails: Array<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
};

export type CancelCheckoutSessionInput = {
sessionId: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
Expand Down Expand Up @@ -2600,6 +2606,8 @@ export type Query = {
userSearch: UserSearchResultCollection;
/** Look up server users */
users: UserSearchResultCollection;
/** Look up server users with a collection of emails */
usersByEmail: UserSearchResultCollection;
/** Validates the slug, to make sure it contains only valid characters and its not taken. */
validateWorkspaceSlug: Scalars['Boolean']['output'];
workspace: Workspace;
Expand Down Expand Up @@ -2746,6 +2754,11 @@ export type QueryUsersArgs = {
};


export type QueryUsersByEmailArgs = {
input: BulkUsersRetrievalInput;
};


export type QueryValidateWorkspaceSlugArgs = {
slug: Scalars['String']['input'];
};
Expand Down Expand Up @@ -4738,6 +4751,7 @@ export type ResolversTypes = {
BranchCreateInput: BranchCreateInput;
BranchDeleteInput: BranchDeleteInput;
BranchUpdateInput: BranchUpdateInput;
BulkUsersRetrievalInput: BulkUsersRetrievalInput;
CancelCheckoutSessionInput: CancelCheckoutSessionInput;
CheckoutSession: ResolverTypeWrapper<CheckoutSession>;
CheckoutSessionInput: CheckoutSessionInput;
Expand Down Expand Up @@ -5029,6 +5043,7 @@ export type ResolversParentTypes = {
BranchCreateInput: BranchCreateInput;
BranchDeleteInput: BranchDeleteInput;
BranchUpdateInput: BranchUpdateInput;
BulkUsersRetrievalInput: BulkUsersRetrievalInput;
CancelCheckoutSessionInput: CancelCheckoutSessionInput;
CheckoutSession: CheckoutSession;
CheckoutSessionInput: CheckoutSessionInput;
Expand Down Expand Up @@ -6166,6 +6181,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
userPwdStrength?: Resolver<ResolversTypes['PasswordStrengthCheckResults'], ParentType, ContextType, RequireFields<QueryUserPwdStrengthArgs, 'pwd'>>;
userSearch?: Resolver<ResolversTypes['UserSearchResultCollection'], ParentType, ContextType, RequireFields<QueryUserSearchArgs, 'archived' | 'emailOnly' | 'limit' | 'query'>>;
users?: Resolver<ResolversTypes['UserSearchResultCollection'], ParentType, ContextType, RequireFields<QueryUsersArgs, 'input'>>;
usersByEmail?: Resolver<ResolversTypes['UserSearchResultCollection'], ParentType, ContextType, RequireFields<QueryUsersByEmailArgs, 'input'>>;
validateWorkspaceSlug?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<QueryValidateWorkspaceSlugArgs, 'slug'>>;
workspace?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<QueryWorkspaceArgs, 'id'>>;
workspaceBySlug?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<QueryWorkspaceBySlugArgs, 'slug'>>;
Expand Down
15 changes: 14 additions & 1 deletion packages/server/modules/core/graph/resolvers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
markOnboardingCompleteFactory,
legacyGetPaginatedUsersCountFactory,
legacyGetPaginatedUsersFactory,
lookupUsersFactory
lookupUsersFactory,
bulkLookupUsersFactory
} from '@/modules/core/repositories/users'
import { UsersMeta } from '@/modules/core/dbSchema'
import { throwForNotHavingServerRole } from '@/modules/shared/authz'
Expand Down Expand Up @@ -69,6 +70,7 @@ const changeUserRole = changeUserRoleFactory({
updateUserServerRole: updateUserServerRoleFactory({ db })
})
const searchUsers = searchUsersFactory({ db })
const bulkLookupUsers = bulkLookupUsersFactory({ db })
const lookupUsers = lookupUsersFactory({ db })
const markOnboardingComplete = markOnboardingCompleteFactory({ db })
const getAdminUsersListCollection = getAdminUsersListCollectionFactory({
Expand Down Expand Up @@ -150,7 +152,18 @@ export = {
const { cursor, users } = await lookupUsers(args.input)
return { cursor, items: users }
},
async usersByEmail(_parent, args) {
if (args.input.emails.length < 1)
throw new BadRequestError('Must provide at least one email to search for.')

if ((args.input.limit || 0) > 100)
throw new BadRequestError(
'Cannot return more than 100 items, please use pagination.'
)

const { cursor, users } = await bulkLookupUsers(args.input)
return { cursor, items: users }
},
async userPwdStrength(_parent, args) {
const res = zxcvbn(args.pwd)
return { score: res.score, feedback: res.feedback }
Expand Down
94 changes: 67 additions & 27 deletions packages/server/modules/core/repositories/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { updateUserEmailFactory } from '@/modules/core/repositories/userEmails'
import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification'
import { UserWithOptionalRole } from '@/modules/core/domain/users/types'
import {
BulkLookupUsers,
CountAdminUsers,
CountUsers,
DeleteUserRecord,
Expand Down Expand Up @@ -449,6 +450,36 @@ export const getUserRoleFactory =
return role as Nullable<ServerRoles>
}

type LookupUsersBaseQueryFilter = {
cursor?: string | null
limit?: number | null
}

export const lookupUsersBaseQuery = (
db: Knex,
filter: LookupUsersBaseQueryFilter = {}
) => {
const query = tables
.users(db)
.join(ServerAcl.name, Users.col.id, ServerAcl.col.userId)
.leftJoin(UserEmails.name, UserEmails.col.userId, Users.col.id)
.columns([
...Object.values(
omit(Users.col, [Users.col.email, Users.col.verified, Users.col.passwordDigest])
),
knex.raw(`(array_agg(??))[1] as "verified"`, [UserEmails.col.verified]),
knex.raw(`(array_agg(??))[1] as "email"`, [UserEmails.col.email])
])
.groupBy(Users.col.id)

if (filter.cursor) query.andWhere(Users.col.createdAt, '<', filter.cursor)

const finalLimit = clamp(filter.limit || 10, 1, 100)
query.orderBy(Users.col.createdAt, 'desc').limit(finalLimit)

return query
}

/**
* Used for (Limited)User search. No need to convert users to Limited here, because non-limited fields
* cannot be leaked out from the GQL API.
Expand All @@ -465,43 +496,52 @@ export const lookupUsersFactory =
projectId
} = filter

const query = tables
.users(deps.db)
.join(ServerAcl.name, Users.col.id, ServerAcl.col.userId)
.leftJoin(UserEmails.name, UserEmails.col.userId, Users.col.id)
.columns([
...Object.values(
omit(Users.col, [
Users.col.email,
Users.col.verified,
Users.col.passwordDigest
])
),
knex.raw(`(array_agg(??))[1] as "verified"`, [UserEmails.col.verified]),
knex.raw(`(array_agg(??))[1] as "email"`, [UserEmails.col.email])
])
.groupBy(Users.col.id)
.where((queryBuilder) => {
queryBuilder.where({ [UserEmails.col.email]: searchQuery }) //match full email or partial name
if (!emailOnly)
queryBuilder.orWhere(Users.col.name, 'ILIKE', `%${searchQuery}%`)
if (!archived)
queryBuilder.andWhere(ServerAcl.col.role, '!=', Roles.Server.ArchivedUser)
})
const query = lookupUsersBaseQuery(deps.db, { limit, cursor })

// match full email or partial name
query.where((queryBuilder) => {
queryBuilder.where({ [UserEmails.col.email]: searchQuery.toLowerCase() })
if (!emailOnly) queryBuilder.orWhere(Users.col.name, 'ILIKE', `%${searchQuery}%`)
if (!archived)
queryBuilder.andWhere(ServerAcl.col.role, '!=', Roles.Server.ArchivedUser)
})

// limit to given project
if (projectId) {
query
.innerJoin(StreamAcl.name, StreamAcl.col.userId, Users.col.id)
.andWhere(StreamAcl.col.resourceId, projectId)
}

if (cursor) query.andWhere(Users.col.createdAt, '<', cursor)
const rows = (await query) as UserRecord[]
const users = rows.map((u) => sanitizeUserRecord(u)) // pw shouldnt be there, but just making sure

const finalLimit = clamp(limit || 10, 1, 100)
query.orderBy(Users.col.createdAt, 'desc').limit(finalLimit)
return {
users,
cursor: users.length > 0 ? users[users.length - 1].createdAt.toISOString() : null
}
}

/**
* Used for (Limited)User search when multiple potential emails are known
* @param deps
* @returns
*/
export const bulkLookupUsersFactory =
(deps: { db: Knex }): BulkLookupUsers =>
async (filter) => {
const { emails, limit, cursor } = filter

const query = lookupUsersBaseQuery(deps.db, { limit, cursor })

// limit to exact matches on provided emails
query.whereIn(
UserEmails.col.email,
emails.map((email) => email.toLowerCase())
)

const rows = (await query) as UserRecord[]
const users = rows.map((u) => sanitizeUserRecord(u)) // pw shouldnt be there, but just making sure
const users = rows.map((u) => sanitizeUserRecord(u))

return {
users,
Expand Down
Loading