Skip to content
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
119 changes: 45 additions & 74 deletions api/main_endpoints/routes/ShortcutSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -37,105 +36,77 @@ 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
});

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 });
Expand Down
16 changes: 8 additions & 8 deletions test/api/ShortcutSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,31 +206,31 @@ 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 () => {
const result = await test.sendPostRequestWithToken(token, url, fiveMatchUsers);
expect(result).to.have.status(OK);
expect(result.body.items.users.map(u => u.email)).to.eql([
'[email protected]',
'test0@test.com',
'test00@test.com',
'test2@test.com',
'test3@test.com'
'test3@test.com',
'test5@test.com',
'test7@test.com',
'test0@test.com'
]);
});
});
Expand Down
Loading