Skip to content

Commit

Permalink
feat(users): bulk lookup users by email (#3720)
Browse files Browse the repository at this point in the history
* feat(users): bulk lookup users by email

* chore(users): add tests for lookups

* chore(users): fe gqlgen

* fix(users): match return value with input
  • Loading branch information
cdriesler authored Jan 7, 2025
1 parent 78773aa commit c791362
Show file tree
Hide file tree
Showing 9 changed files with 308 additions and 29 deletions.
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 @@ -7624,6 +7637,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!): [LimitedUser]!
@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>
}>

/**
* @returns An array of matches in order provided, or null for positions where no match found for email.
*/
export type BulkLookupUsers = (filter: {
emails: string[]
/**
* Defaults to 10
*/
limit?: MaybeNullOrUndefined<number>
cursor?: MaybeNullOrUndefined<string>
}) => Promise<(User | null)[]>

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: Array<Maybe<LimitedUser>>;
/** 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 @@ -4740,6 +4753,7 @@ export type ResolversTypes = {
BranchCreateInput: BranchCreateInput;
BranchDeleteInput: BranchDeleteInput;
BranchUpdateInput: BranchUpdateInput;
BulkUsersRetrievalInput: BulkUsersRetrievalInput;
CancelCheckoutSessionInput: CancelCheckoutSessionInput;
CheckoutSession: ResolverTypeWrapper<CheckoutSession>;
CheckoutSessionInput: CheckoutSessionInput;
Expand Down Expand Up @@ -5031,6 +5045,7 @@ export type ResolversParentTypes = {
BranchCreateInput: BranchCreateInput;
BranchDeleteInput: BranchDeleteInput;
BranchUpdateInput: BranchUpdateInput;
BulkUsersRetrievalInput: BulkUsersRetrievalInput;
CancelCheckoutSessionInput: CancelCheckoutSessionInput;
CheckoutSession: CheckoutSession;
CheckoutSessionInput: CheckoutSessionInput;
Expand Down Expand Up @@ -6168,6 +6183,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<Array<Maybe<ResolversTypes['LimitedUser']>>, 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
14 changes: 13 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,17 @@ 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) > 20)
throw new BadRequestError(
'Cannot return more than 20 items, please use a shorter list.'
)

return await bulkLookupUsers(args.input)
},
async userPwdStrength(_parent, args) {
const res = zxcvbn(args.pwd)
return { score: res.score, feedback: res.feedback }
Expand Down
95 changes: 67 additions & 28 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,41 +496,23 @@ 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 finalLimit = clamp(limit || 10, 1, 100)
query.orderBy(Users.col.createdAt, 'desc').limit(finalLimit)

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

Expand All @@ -509,6 +522,32 @@ export const lookupUsersFactory =
}
}

/**
* 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 matches = (await query) as UserRecord[]
const result = emails.map((email) =>
matches.find((user) => user.email === email.toLowerCase())
)

return result.map((user) => (user ? sanitizeUserRecord(user) : null))
}

/**
* User search available for normal server users. It's more limited because of the lower access level.
* @deprecated Use lookupUsers instead
Expand Down
Loading

0 comments on commit c791362

Please sign in to comment.