diff --git a/api/main_endpoints/routes/ShortcutSearch.js b/api/main_endpoints/routes/ShortcutSearch.js index 2abd6a049..1f0357e91 100644 --- a/api/main_endpoints/routes/ShortcutSearch.js +++ b/api/main_endpoints/routes/ShortcutSearch.js @@ -19,8 +19,7 @@ const { Cleezy } = require('../../config/config.json'); const { ENABLED } = Cleezy; const cleezy = require('../routes/Cleezy.js'); -// Search for all members using either first name, last name or email -// Search for all cleezy urls using either alias or url +// Search for all members using either first name, last name or email and for all cleezy urls using either alias or url router.post('/', async function(req, res) { if (!checkIfTokenSent(req)) { return res.sendStatus(FORBIDDEN); @@ -37,87 +36,59 @@ router.post('/', async function(req, res) { }); } - const query = req.body.query.replace(/[*\s]/g, ''); - - // Create a fuzzy regex pattern to match characters in order, e.g., "pone" -> /p.*o.*n.*e/i - const fuzzyPattern = query.split('').join('.*'); - const pattern = new RegExp(fuzzyPattern, 'i'); - - const maybeOr = { - $or: [ - { - $expr: { - $regexMatch: { - input: { $concat: ['$firstName', '$lastName'] }, - regex: pattern, - } - } - }, - { email: { $regex: new RegExp(query, 'i')} } - ] - }; - - /** - * Function to calculate scores based on token matches for sorting - * @param {string} str - The string to score against - * @param {Array} tokens - The tokens to match against the string - * @return {number} - The score based on matches - */ - const tokenScores = (str, tokens) => { - return tokens.reduce((score, token) => { - if (str.startsWith(token)) return score; // highest score for exact match - if (str.includes(token)) return score + 1; // lower score for partial match - return score + 2; // lowest score for no match - }, 0); - }; - - /** - * Sorts the user items based on the query match - * @param {string} query input string to match against - * @returns {function} - A comparison function for sorting - */ - const sortByMatch = (query) => { - const input = query.toLowerCase().split(/[\s@._-]+/).filter(Boolean); - - return (a, b) => { - const aName = (a.firstName + ' ' + a.lastName).toLowerCase(); - const bName = (b.firstName + ' ' + b.lastName).toLowerCase(); - const aEmail = a.email.toLowerCase(); - const bEmail = b.email.toLowerCase(); - - // First Priority: sort by name match - const nameScoreA = tokenScores(aName, input); - const nameScoreB = tokenScores(bName, input); - if (nameScoreA !== nameScoreB) { - return nameScoreA - nameScoreB; + // function for calculating how similar strings are to each other via levenshtein distance + function levenshteinDistance(a, b) { + const m = a.length; + const n = b.length; + + if (m === 0) return n; + if (n === 0) return m; + + const dp = Array.from({ length: m + 1 }, () => Array(n + 1)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost + ); } + } + return dp[m][n]; + } - // Second Priority: sort by email match - const emailScoreA = tokenScores(aEmail, input); - const emailScoreB = tokenScores(bEmail, input); - if (emailScoreA !== emailScoreB) { - return emailScoreA - emailScoreB; - } + // Find top 5 matching users and sort results based on best match of name or email + try { + User.find({}, { password: 0 }); + const matchingUsers = users.map(user => { + const firstNameScore = levenshteinDistance(req.body.query, user.firstName); + const lastNameScore = levenshteinDistance(req.body.query, user.lastName); + const emailScore = levenshteinDistance(req.body.query, user.email); + return { + user, + score: Math.min(firstNameScore, lastNameScore, emailScore) + }; + }); - // Tie-breaker: alphabetical email sort - return a.email.localeCompare(b.email); - }; - }; + const topUsers = matchingUsers + .sort((a, b) => a.score - b.score) + .slice(0, 5) + .map(item => item.user); - // Find user and sort results based on best match of full name or email - try{ - const users = await User.find(maybeOr, { password: 0 }).limit(5); - users.sort(sortByMatch(req.body.query)); + const cleezyRes = await cleezy.searchCleezyUrls(req); // Short circuit if cleezy is disabled if(!ENABLED) { return res.status(OK).json({ - items: {users, cleezyData: []}, + items: {users: topUsers, cleezyData: []}, disabled: true }); } - const cleezyRes = await cleezy.searchCleezyUrls(req); if (cleezyRes.status !== OK) { logger.warn('Cleezy search failed', { status: cleezyRes.status @@ -125,17 +96,17 @@ router.post('/', async function(req, res) { return res.status(OK).send({ cleezyStatus: cleezyRes.status, - items: { users } + items: { users: topUsers } }); } return res.status(OK).send({ items: { - users, + users: topUsers, cleezyData: cleezyRes.data, } }); - } catch (error) { + } catch(error) { logger.error('/shortcutsearch encountered an error:', { error, query: req.body.query }); if (error.response && error.response.data) { res.status(error.response.status).json({ error: error.response.data }); diff --git a/test/api/ShortcutSearch.js b/test/api/ShortcutSearch.js index faa61dcde..b8c2dbff4 100644 --- a/test/api/ShortcutSearch.js +++ b/test/api/ShortcutSearch.js @@ -206,20 +206,20 @@ describe('ShortcutSearch', () => { expect(result.body.items.users).that.is.an('array').to.have.lengthOf(5); }); - it('Should return no records when query = \'Pika\'', async () => { + it('Should return FIVE records when query = \'Pika\'', async () => { const result = await test.sendPostRequestWithToken(token, url, { query: 'Pika' }); expect(result).to.have.status(OK); - expect(result.body.items.users).that.is.an('array').that.is.empty; + expect(result.body.items.users).that.is.an('array').to.have.lengthOf(5); }); beforeEach(() => { setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.ADMIN }); }); - it('Should return THREE records when query = \'coOl\'', async () => { + it('Should return FIVE records when query = \'coOl\'', async () => { const result = await test.sendPostRequestWithToken(token, url, queryUser); expect(result).to.have.status(OK); - expect(result.body.items.users).that.is.an('array').to.have.lengthOf(3); + expect(result.body.items.users).that.is.an('array').to.have.lengthOf(5); }); it('Should show results sorted by best match of name and email', async () => { @@ -227,10 +227,10 @@ describe('ShortcutSearch', () => { expect(result).to.have.status(OK); expect(result.body.items.users.map(u => u.email)).to.eql([ 'test1@test.com', - 'test0@test.com', - 'test00@test.com', - 'test2@test.com', - 'test3@test.com' + 'test3@test.com', + 'test5@test.com', + 'test7@test.com', + 'test0@test.com' ]); }); });