Skip to content

Commit b0c3462

Browse files
committed
Initial search by EMSI skills
1 parent eafa66f commit b0c3462

File tree

8 files changed

+228
-26
lines changed

8 files changed

+228
-26
lines changed

.nvmrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v8.9.2
1+
v12.22.12

app-constants.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const MAMBO_GET_REWARDS_ALLOWED_FIELDS = [
2828
'awardedOn', 'expiryOn', 'isExpired', 'id'
2929
]
3030

31+
const BOOLEAN_OPERATOR = {
32+
AND: 'AND',
33+
OR: 'OR'
34+
}
3135
module.exports = {
3236
ADMIN_ROLES,
3337
SEARCH_BY_EMAIL_ROLES,
@@ -36,5 +40,6 @@ module.exports = {
3640
EVENT_MIME_TYPE,
3741
TOPICS,
3842
ES_SEARCH_MAX_SIZE,
39-
MAMBO_GET_REWARDS_ALLOWED_FIELDS
43+
MAMBO_GET_REWARDS_ALLOWED_FIELDS,
44+
BOOLEAN_OPERATOR
4045
}

src/common/eshelper.js

+92-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
const _ = require('lodash')
55
const config = require('config')
6+
const { BOOLEAN_OPERATOR } = require('../../app-constants')
67

78
/**
89
* Fetch members profile form ES
@@ -58,6 +59,35 @@ async function getMembers (query, esClient, currentUser) {
5859
return docsMembers
5960
}
6061

62+
/**
63+
* Search members by skills
64+
* @param {Object} query the HTTP request query
65+
* @returns {Object} members skills
66+
*/
67+
async function searchBySkills (query, esClient) {
68+
// construct ES query for skills
69+
const esQuerySkills = {
70+
index: config.get('ES.MEMBER_SKILLS_ES_INDEX'),
71+
type: config.get('ES.MEMBER_SKILLS_ES_TYPE'),
72+
body: {
73+
sort: [{ userHandle: { order: query.sort } }]
74+
}
75+
}
76+
const boolQuerySkills = []
77+
78+
if (query.handlesLower) {
79+
boolQuerySkills.push({ query: { terms: { handleLower: query.handlesLower } } })
80+
}
81+
esQuerySkills.body.query = {
82+
bool: {
83+
filter: boolQuerySkills
84+
}
85+
}
86+
// search with constructed query
87+
const docsSkills = await esClient.search(esQuerySkills)
88+
return docsSkills
89+
}
90+
6191
/**
6292
* Fetch members skills form ES
6393
* @param {Object} query the HTTP request query
@@ -146,6 +176,66 @@ async function getSuggestion (query, esClient, currentUser) {
146176
return docsSuggestionMembers
147177
}
148178

179+
/**
180+
* Gets the members skills documents matching the provided criteria from Elasticsearch
181+
* @param skillIds
182+
* @param skillsBooleanOperator
183+
* @param page
184+
* @param perPage
185+
* @param esClient
186+
* @returns {Promise<*>}
187+
*/
188+
async function searchMembersSkills (skillIds, skillsBooleanOperator, page, perPage, esClient) {
189+
// construct ES query for members skills
190+
const esQuerySkills = {
191+
index: config.get('ES.MEMBER_PROFILE_ES_INDEX'),
192+
type: config.get('ES.MEMBER_PROFILE_ES_TYPE'),
193+
from: 0,
194+
size: 100,
195+
body: {
196+
sort: [{ createdAt: { order: 'desc' } }],
197+
query: {
198+
bool: {
199+
filter: { bool: {} }
200+
}
201+
}
202+
}
203+
}
204+
205+
const mustMatchQuery = [] // will contain the filters with AND operator
206+
const shouldFilter = [] // will contain the filters with OR operator
207+
208+
if (skillsBooleanOperator === BOOLEAN_OPERATOR.AND) {
209+
for (const skillId of skillIds) {
210+
const matchPhrase = {}
211+
matchPhrase[`emsiSkills.emsiId`] = `${skillId}`
212+
mustMatchQuery.push({
213+
match_phrase: matchPhrase
214+
})
215+
}
216+
} else {
217+
for (const skillId of skillIds) {
218+
const matchPhrase = {}
219+
matchPhrase[`emsiSkills.emsiId`] = `${skillId}`
220+
shouldFilter.push({
221+
match_phrase: matchPhrase// eslint-disable-line
222+
})
223+
}
224+
}
225+
226+
if (mustMatchQuery.length > 0) {
227+
esQuerySkills.body.query.bool.filter.bool.must = mustMatchQuery
228+
}
229+
230+
if (shouldFilter.length > 0) {
231+
esQuerySkills.body.query.bool.filter.bool.should = shouldFilter
232+
}
233+
// search with constructed query
234+
return esClient.search(esQuerySkills)
235+
}
236+
237+
238+
149239
/**
150240
* Get total items
151241
* @param {Object} docs the HTTP request query
@@ -164,5 +254,6 @@ module.exports = {
164254
getMembersSkills,
165255
getMembersStats,
166256
getSuggestion,
167-
getTotal
257+
getTotal,
258+
searchMembersSkills,
168259
}

src/common/helper.js

+22-1
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,26 @@ const getM2MToken = () => {
779779
)
780780
}
781781

782+
/**
783+
* Gets the list of parameters from the query as an array
784+
*
785+
* @param query
786+
* @param parameterName
787+
* @returns {*[]}
788+
*/
789+
const getParamsFromQueryAsArray = async (query, parameterName) => {
790+
const paramsArray = []
791+
if (!_.isEmpty(query[parameterName])) {
792+
if (!_.isArray(query[parameterName])) {
793+
paramsArray.push(query[parameterName])
794+
} else {
795+
paramsArray.push(...query[parameterName])
796+
}
797+
}
798+
return paramsArray
799+
}
800+
801+
782802
module.exports = {
783803
wrapExpress,
784804
autoWrapExpress,
@@ -812,5 +832,6 @@ module.exports = {
812832
getGroupId,
813833
getAllowedGroupIds,
814834
getMemberGroups,
815-
getM2MToken
835+
getM2MToken,
836+
getParamsFromQueryAsArray
816837
}

src/controllers/SearchController.js

+11
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,18 @@ async function autocomplete (req, res) {
2626
res.send(result.result)
2727
}
2828

29+
/**
30+
* Search members with additional parameters, like skills
31+
* @param {Object} req the request
32+
* @param {Object} res the response
33+
*/
34+
async function searchMembersBySkills (req, res) {
35+
const result = await service.searchMembersBySkills(req.authUser, req.query)
36+
helper.setResHeaders(req, res, result)
37+
res.send(result.result)
38+
}
2939
module.exports = {
3040
searchMembers,
41+
searchMembersBySkills,
3142
autocomplete
3243
}

src/routes.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ module.exports = {
2323
scopes: [MEMBERS.READ, MEMBERS.ALL]
2424
}
2525
},
26-
'/search/members/autocomplete': {
26+
'/members/searchBySkills': {
2727
get: {
2828
controller: 'SearchController',
29-
method: 'autocomplete',
29+
method: 'searchMembersBySkills',
3030
auth: 'jwt',
3131
scopes: [MEMBERS.READ, MEMBERS.ALL]
3232
}
@@ -160,5 +160,5 @@ module.exports = {
160160
allowNoToken: true,
161161
scopes: [MEMBERS.READ, MEMBERS.ALL]
162162
}
163-
},
163+
}
164164
}

src/scripts/view-es-data.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ if (process.argv.length <= 2) {
1212
process.exit(1)
1313
}
1414
const indexName = process.argv[2]
15+
const indexType = process.argv[3]
1516

1617
const esClient = helper.getESClient()
1718

1819
async function showESData () {
1920
const result = await esClient.search({
2021
index: indexName,
21-
type: config.get('ES.MEMBER_PROFILE_ES_TYPE') // type name is same for all indices
22+
type: indexType // type name is same for all indices
2223
})
2324
return result.hits.hits || []
2425
}

src/services/SearchService.js

+91-18
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ const helper = require('../common/helper')
99
const eshelper = require('../common/eshelper')
1010
const logger = require('../common/logger')
1111
const errors = require('../common/errors')
12+
const { BOOLEAN_OPERATOR } = require('../../app-constants')
1213

1314
const MEMBER_FIELDS = ['userId', 'handle', 'handleLower', 'firstName', 'lastName',
1415
'status', 'addresses', 'photoURL', 'homeCountryCode', 'competitionCountryCode',
1516
'description', 'email', 'tracks', 'maxRating', 'wins', 'createdAt', 'createdBy',
1617
'updatedAt', 'updatedBy', 'skills', 'stats', 'emsiSkills']
1718

19+
const MEMBER_SORT_BY_FIELDS = ['userId', 'country', 'handle', 'firstName', 'lastName',
20+
'accountAge', 'numberOfChallengesWon',
21+
'numberOfChallengesPlaced']
22+
1823
const MEMBER_AUTOCOMPLETE_FIELDS = ['userId', 'handle', 'handleLower',
1924
'status', 'email', 'createdAt', 'updatedAt']
2025

@@ -39,19 +44,42 @@ async function searchMembers (currentUser, query) {
3944
}
4045

4146
if (query.email != null && query.email.length > 0) {
42-
if (currentUser == null) {
43-
throw new errors.UnauthorizedError("Authentication token is required to query users by email");
44-
}
45-
if (!helper.hasSearchByEmailRole(currentUser)) {
46-
throw new errors.BadRequestError("Admin role is required to query users by email");
47-
}
47+
if (currentUser == null) {
48+
throw new errors.UnauthorizedError('Authentication token is required to query users by email')
49+
}
50+
if (!helper.hasSearchByEmailRole(currentUser)) {
51+
throw new errors.BadRequestError('Admin role is required to query users by email')
52+
}
4853
}
4954

5055
// search for the members based on query
5156
const docsMembers = await eshelper.getMembers(query, esClient, currentUser)
5257

58+
return fillMembers(docsMembers, query, fields)
59+
}
60+
61+
searchMembers.schema = {
62+
currentUser: Joi.any(),
63+
query: Joi.object().keys({
64+
handleLower: Joi.string(),
65+
handlesLower: Joi.array(),
66+
handle: Joi.string(),
67+
handles: Joi.array(),
68+
email: Joi.string(),
69+
userId: Joi.number(),
70+
userIds: Joi.array(),
71+
term: Joi.string(),
72+
fields: Joi.string(),
73+
page: Joi.page(),
74+
perPage: Joi.perPage(),
75+
sort: Joi.sort()
76+
})
77+
}
78+
79+
async function fillMembers(docsMembers, query, fields) {
5380
// get the total
5481
const total = eshelper.getTotal(docsMembers)
82+
5583
let results = []
5684
if (total > 0) {
5785
// extract member profiles from hits
@@ -108,24 +136,68 @@ async function searchMembers (currentUser, query) {
108136
return { total: total, page: query.page, perPage: query.perPage, result: results }
109137
}
110138

111-
searchMembers.schema = {
139+
// TODO - use some caching approach to replace these in-memory objects
140+
/**
141+
* Search members by the given search query
142+
*
143+
* @param query The search query by which to search members
144+
*
145+
* @returns {Promise<[]>} The array of members matching the given query
146+
*/
147+
const searchMembersBySkills = async (currentUser, query) => {
148+
const esClient = await helper.getESClient()
149+
let skillIds = await helper.getParamsFromQueryAsArray(query, 'skillId')
150+
const result = searchMembersBySkillsWithOptions(currentUser, query, skillIds, BOOLEAN_OPERATOR.AND, query.page, query.perPage, query.sortBy, query.sortOrder, esClient)
151+
return result
152+
}
153+
154+
searchMembersBySkills.schema = {
112155
currentUser: Joi.any(),
113156
query: Joi.object().keys({
114-
handleLower: Joi.string(),
115-
handlesLower: Joi.array(),
116-
handle: Joi.string(),
117-
handles: Joi.array(),
118-
email: Joi.string(),
119-
userId: Joi.number(),
120-
userIds: Joi.array(),
121-
term: Joi.string(),
122-
fields: Joi.string(),
157+
skillId: Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string())),
123158
page: Joi.page(),
124159
perPage: Joi.perPage(),
125-
sort: Joi.sort()
160+
sortBy: Joi.string().valid(MEMBER_SORT_BY_FIELDS).default('numberOfChallengesWon'),
161+
sortOrder: Joi.string().valid('asc', 'desc').default('desc')
126162
})
127163
}
128164

165+
/**
166+
* Search members matching the given skills
167+
*
168+
* @param currentUser
169+
* @param skillsFilter
170+
* @param skillsBooleanOperator
171+
* @param page
172+
* @param perPage
173+
* @param sortBy
174+
* @param sortOrder
175+
* @param esClient
176+
* @returns {Promise<*[]|{total, perPage, numberOfPages: number, data: *[], page}>}
177+
*/
178+
const searchMembersBySkillsWithOptions = async (currentUser, query, skillsFilter, skillsBooleanOperator, page, perPage, sortBy, sortOrder, esClient) => {
179+
let fields = helper.parseCommaSeparatedString(query.fields, MEMBER_FIELDS) || MEMBER_FIELDS
180+
// if current user is not admin and not M2M, then exclude the admin/M2M only fields
181+
if (!currentUser || (!currentUser.isMachine && !helper.hasAdminRole(currentUser))) {
182+
fields = _.without(fields, ...config.SEARCH_SECURE_FIELDS)
183+
MEMBER_STATS_FIELDS = _.without(MEMBER_STATS_FIELDS, ...config.STATISTICS_SECURE_FIELDS)
184+
}
185+
186+
const emptyResult = {
187+
total: 0,
188+
page,
189+
perPage,
190+
numberOfPages: 0,
191+
data: []
192+
}
193+
if (_.isEmpty(skillsFilter)) {
194+
return emptyResult
195+
}
196+
197+
const membersSkillsDocs = await eshelper.searchMembersSkills(skillsFilter, skillsBooleanOperator, page, perPage, esClient)
198+
199+
return fillMembers(membersSkillsDocs, query, fields)
200+
}
129201
/**
130202
* members autocomplete.
131203
* @param {Object} currentUser the user who performs operation
@@ -148,7 +220,7 @@ async function autocomplete (currentUser, query) {
148220
// custom filter & sort
149221
let regex = new RegExp(`^${query.term}`, `i`)
150222
// sometimes .payload is not defined. so use _source instead
151-
results = results.map(x => ({...x, payload: x.payload || x._source}))
223+
results = results.map(x => ({ ...x, payload: x.payload || x._source }))
152224
results = results
153225
.filter(x => regex.test(x.payload.handle))
154226
.sort((a, b) => a.payload.handle.localeCompare(b.payload.handle))
@@ -175,6 +247,7 @@ autocomplete.schema = {
175247

176248
module.exports = {
177249
searchMembers,
250+
searchMembersBySkills,
178251
autocomplete
179252
}
180253

0 commit comments

Comments
 (0)