From 2810c9d0a52711c3f49e3e7cf82e2af5cda941ea Mon Sep 17 00:00:00 2001 From: Aidan Spies Date: Wed, 30 Jul 2025 17:39:53 -0700 Subject: [PATCH 1/6] Implemented levenshtein distance algorithm and changed unit tests to match --- api/main_endpoints/routes/ShortcutSearch.js | 103 +++++++++----------- test/api/ShortcutSearch.js | 16 +-- 2 files changed, 52 insertions(+), 67 deletions(-) diff --git a/api/main_endpoints/routes/ShortcutSearch.js b/api/main_endpoints/routes/ShortcutSearch.js index 2abd6a049..1bdbbfaa8 100644 --- a/api/main_endpoints/routes/ShortcutSearch.js +++ b/api/main_endpoints/routes/ShortcutSearch.js @@ -37,68 +37,53 @@ 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 + User.find({}, { password: 0 }) + .then(users => { + 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) + }; + }); + const items = matchingUsers + .sort((a, b) => a.score - b.score) + .slice(0, 5) + .map(item => item.user); + res.status(OK).send({ items }); + }) + .catch((error) => { + logger.error('/shortcutsearchusers encountered an error:', error); + res.sendStatus(BAD_REQUEST); // Tie-breaker: alphabetical email sort return a.email.localeCompare(b.email); }; diff --git a/test/api/ShortcutSearch.js b/test/api/ShortcutSearch.js index faa61dcde..bb9ddb363 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).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).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' ]); }); }); From ce0dd5e2a59c454904b112f7adf6111ca1336c0a Mon Sep 17 00:00:00 2001 From: Aidan Spies Date: Wed, 30 Jul 2025 18:12:33 -0700 Subject: [PATCH 2/6] Merge code w cleezySearchURLs code --- api/main_endpoints/routes/ShortcutSearch.js | 75 +++++++++------------ 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/api/main_endpoints/routes/ShortcutSearch.js b/api/main_endpoints/routes/ShortcutSearch.js index 1bdbbfaa8..ca8e2041f 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); @@ -63,8 +62,8 @@ router.post('/', async function(req, res) { } // Find top 5 matching users and sort results based on best match of name or email - User.find({}, { password: 0 }) - .then(users => { + User.find({}, { password: 0 }) + .then(async users => { const matchingUsers = users.map(user => { const firstNameScore = levenshteinDistance(req.body.query, user.firstName); const lastNameScore = levenshteinDistance(req.body.query, user.lastName); @@ -75,59 +74,47 @@ router.post('/', async function(req, res) { }; }); - const items = matchingUsers + const users = matchingUsers .sort((a, b) => a.score - b.score) .slice(0, 5) .map(item => item.user); - res.status(OK).send({ items }); - }) - .catch((error) => { - logger.error('/shortcutsearchusers encountered an error:', error); - res.sendStatus(BAD_REQUEST); - // Tie-breaker: alphabetical email sort - return a.email.localeCompare(b.email); - }; - }; - // 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: []}, - disabled: true - }); - } + // Short circuit if cleezy is disabled + if(!ENABLED) { + return res.status(OK).json({ + items: {users, cleezyData: []}, + disabled: true + }); + } - const cleezyRes = await cleezy.searchCleezyUrls(req); - if (cleezyRes.status !== OK) { - logger.warn('Cleezy search failed', { - status: cleezyRes.status - }); + if (cleezyRes.status !== OK) { + logger.warn('Cleezy search failed', { + status: cleezyRes.status + }); - return res.status(OK).send({ - cleezyStatus: cleezyRes.status, - items: { users } - }); - } + return res.status(OK).send({ + cleezyStatus: cleezyRes.status, + items: { users } + }); + } - return res.status(OK).send({ + return res.status(OK).send({ items: { users, cleezyData: cleezyRes.data, } + .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 }); + } else { + res.status(SERVER_ERROR).json({ error: 'Failed to search for Users or URLs' }); + } + }), }); - } 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 }); - } else { - res.status(SERVER_ERROR).json({ error: 'Failed to search for Users or URLs' }); - } - } + }); }); module.exports = router; From 41f17febcbba02469cb463ff1945063a17d58570 Mon Sep 17 00:00:00 2001 From: Aidan Spies Date: Wed, 30 Jul 2025 18:14:49 -0700 Subject: [PATCH 3/6] Merged changes in the test api as well --- test/api/ShortcutSearch.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/ShortcutSearch.js b/test/api/ShortcutSearch.js index bb9ddb363..b8c2dbff4 100644 --- a/test/api/ShortcutSearch.js +++ b/test/api/ShortcutSearch.js @@ -209,7 +209,7 @@ describe('ShortcutSearch', () => { 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).that.is.an('array').to.have.lengthOf(5); + expect(result.body.items.users).that.is.an('array').to.have.lengthOf(5); }); beforeEach(() => { @@ -219,7 +219,7 @@ describe('ShortcutSearch', () => { 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).that.is.an('array').to.have.lengthOf(5); + 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 () => { From 760b03c65d6c4798cd35ce7305ee3be8a918e670 Mon Sep 17 00:00:00 2001 From: Aidan Spies Date: Wed, 30 Jul 2025 18:24:42 -0700 Subject: [PATCH 4/6] linting fixes --- api/main_endpoints/routes/ShortcutSearch.js | 91 +++++++++++---------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/api/main_endpoints/routes/ShortcutSearch.js b/api/main_endpoints/routes/ShortcutSearch.js index ca8e2041f..1489bc4c3 100644 --- a/api/main_endpoints/routes/ShortcutSearch.js +++ b/api/main_endpoints/routes/ShortcutSearch.js @@ -62,59 +62,60 @@ router.post('/', async function(req, res) { } // Find top 5 matching users and sort results based on best match of name or email + try { User.find({}, { password: 0 }) - .then(async users => { - 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) - }; - }); + .then(async users => { + 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) + }; + }); - const users = matchingUsers - .sort((a, b) => a.score - b.score) - .slice(0, 5) - .map(item => item.user); + const topUsers = matchingUsers + .sort((a, b) => a.score - b.score) + .slice(0, 5) + .map(item => item.user); - const cleezyRes = await cleezy.searchCleezyUrls(req); + const cleezyRes = await cleezy.searchCleezyUrls(req); - // Short circuit if cleezy is disabled - if(!ENABLED) { - return res.status(OK).json({ - items: {users, cleezyData: []}, - disabled: true - }); - } + // Short circuit if cleezy is disabled + if(!ENABLED) { + return res.status(OK).json({ + items: {users: topUsers, cleezyData: []}, + disabled: true + }); + } - if (cleezyRes.status !== OK) { - logger.warn('Cleezy search failed', { - status: cleezyRes.status - }); + if (cleezyRes.status !== OK) { + logger.warn('Cleezy search failed', { + status: cleezyRes.status + }); + + return res.status(OK).send({ + cleezyStatus: cleezyRes.status, + items: { users: topUsers } + }); + } return res.status(OK).send({ - cleezyStatus: cleezyRes.status, - items: { users } + items: { + users: topUsers, + cleezyData: cleezyRes.data, + } }); - } - - return res.status(OK).send({ - items: { - users, - cleezyData: cleezyRes.data, - } - .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 }); - } else { - res.status(SERVER_ERROR).json({ error: 'Failed to search for Users or URLs' }); - } - }), - }); - }); + }); + } 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 }); + } else { + res.status(SERVER_ERROR).json({ error: 'Failed to search for Users or URLs' }); + } + } }); module.exports = router; From 399e5d0c62bea3eca43b7cbc065d40d74ea26d6a Mon Sep 17 00:00:00 2001 From: Aidan Spies Date: Wed, 30 Jul 2025 18:29:32 -0700 Subject: [PATCH 5/6] Got rid of unecessary .then block --- api/main_endpoints/routes/ShortcutSearch.js | 70 ++++++++++----------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/api/main_endpoints/routes/ShortcutSearch.js b/api/main_endpoints/routes/ShortcutSearch.js index 1489bc4c3..ff8e7759f 100644 --- a/api/main_endpoints/routes/ShortcutSearch.js +++ b/api/main_endpoints/routes/ShortcutSearch.js @@ -64,49 +64,47 @@ router.post('/', async function(req, res) { // Find top 5 matching users and sort results based on best match of name or email try { User.find({}, { password: 0 }) - .then(async users => { - 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) - }; - }); - - const topUsers = matchingUsers - .sort((a, b) => a.score - b.score) - .slice(0, 5) - .map(item => item.user); + 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) + }; + }); - const cleezyRes = await cleezy.searchCleezyUrls(req); + const topUsers = matchingUsers + .sort((a, b) => a.score - b.score) + .slice(0, 5) + .map(item => item.user); - // Short circuit if cleezy is disabled - if(!ENABLED) { - return res.status(OK).json({ - items: {users: topUsers, cleezyData: []}, - disabled: true - }); - } + const cleezyRes = await cleezy.searchCleezyUrls(req); - if (cleezyRes.status !== OK) { - logger.warn('Cleezy search failed', { - status: cleezyRes.status - }); + // Short circuit if cleezy is disabled + if(!ENABLED) { + return res.status(OK).json({ + items: {users: topUsers, cleezyData: []}, + disabled: true + }); + } - return res.status(OK).send({ - cleezyStatus: cleezyRes.status, - items: { users: topUsers } - }); - } + if (cleezyRes.status !== OK) { + logger.warn('Cleezy search failed', { + status: cleezyRes.status + }); return res.status(OK).send({ - items: { - users: topUsers, - cleezyData: cleezyRes.data, - } + cleezyStatus: cleezyRes.status, + items: { users: topUsers } }); + } + + return res.status(OK).send({ + items: { + users: topUsers, + cleezyData: cleezyRes.data, + } }); } catch(error) { logger.error('/shortcutsearch encountered an error:', { error, query: req.body.query }); From b85e47509cee4eb7dc3fd7f7c00823c1838fbc98 Mon Sep 17 00:00:00 2001 From: Aidan Spies Date: Wed, 30 Jul 2025 18:30:59 -0700 Subject: [PATCH 6/6] another round of linting fixes --- api/main_endpoints/routes/ShortcutSearch.js | 72 ++++++++++----------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/api/main_endpoints/routes/ShortcutSearch.js b/api/main_endpoints/routes/ShortcutSearch.js index ff8e7759f..1f0357e91 100644 --- a/api/main_endpoints/routes/ShortcutSearch.js +++ b/api/main_endpoints/routes/ShortcutSearch.js @@ -63,49 +63,49 @@ router.post('/', async function(req, res) { // 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) - }; - }); - - const topUsers = matchingUsers - .sort((a, b) => a.score - b.score) - .slice(0, 5) - .map(item => item.user); + 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) + }; + }); - const cleezyRes = await cleezy.searchCleezyUrls(req); + const topUsers = matchingUsers + .sort((a, b) => a.score - b.score) + .slice(0, 5) + .map(item => item.user); - // Short circuit if cleezy is disabled - if(!ENABLED) { - return res.status(OK).json({ - items: {users: topUsers, cleezyData: []}, - disabled: true - }); - } + const cleezyRes = await cleezy.searchCleezyUrls(req); - if (cleezyRes.status !== OK) { - logger.warn('Cleezy search failed', { - status: cleezyRes.status - }); + // Short circuit if cleezy is disabled + if(!ENABLED) { + return res.status(OK).json({ + items: {users: topUsers, cleezyData: []}, + disabled: true + }); + } - return res.status(OK).send({ - cleezyStatus: cleezyRes.status, - items: { users: topUsers } - }); - } + if (cleezyRes.status !== OK) { + logger.warn('Cleezy search failed', { + status: cleezyRes.status + }); return res.status(OK).send({ - items: { - users: topUsers, - cleezyData: cleezyRes.data, - } + cleezyStatus: cleezyRes.status, + items: { users: topUsers } }); + } + + return res.status(OK).send({ + items: { + users: topUsers, + cleezyData: cleezyRes.data, + } + }); } catch(error) { logger.error('/shortcutsearch encountered an error:', { error, query: req.body.query }); if (error.response && error.response.data) {