diff --git a/api/main_endpoints/routes/User.js b/api/main_endpoints/routes/User.js
index b325acf56..6efe4554b 100644
--- a/api/main_endpoints/routes/User.js
+++ b/api/main_endpoints/routes/User.js
@@ -511,5 +511,96 @@ router.get('/getNewPaidMembersThisSemester', async (req, res) => {
}
});
+// Search for all members using either first name, last name or email
+router.post('/shortcutsearchusers', async function(req, res) {
+ if (!checkIfTokenSent(req)) {
+ return res.sendStatus(FORBIDDEN);
+ } else if (!checkIfTokenValid(req, membershipState.OFFICER)) {
+ return res.sendStatus(UNAUTHORIZED);
+ }
+
+ if (!req.body.query) {
+ return res.status(OK).send({ items: [] });
+ }
+
+ 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 + 0; // 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;
+ }
+
+ // Second Priority: sort by email match
+ const emailScoreA = tokenScores(aEmail, input);
+ const emailScoreB = tokenScores(bEmail, input);
+ if (emailScoreA !== emailScoreB) {
+ return emailScoreA - emailScoreB;
+ }
+
+ // 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
+ User.find(maybeOr, { password: 0 })
+ .limit(5)
+ .then(items => {
+ items.sort(sortByMatch(req.body.query));
+ res.status(OK).send({ items });
+ })
+ .catch((error) => {
+ logger.error('/shortcutsearchusers encountered an error:', error);
+ res.sendStatus(BAD_REQUEST);
+ });
+});
module.exports = router;
diff --git a/src/APIFunctions/UserSearch.js b/src/APIFunctions/UserSearch.js
new file mode 100644
index 000000000..f7af5a97d
--- /dev/null
+++ b/src/APIFunctions/UserSearch.js
@@ -0,0 +1,39 @@
+import { UserApiResponse } from './ApiResponses';
+import { BASE_API_URL } from '../Enums';
+
+/**
+ * Queries the database for all users.
+ * @param {string} token The jwt token for verification
+ * @param {string} query The search query to filter users by name or email.
+ * @returns {UserApiResponse} Containing any error information or the array of
+ * users.
+ */
+export async function searchAllUsers({
+ token,
+ query = null
+}) {
+ const url = new URL('/api/User/shortcutsearchusers', BASE_API_URL);
+
+ let status = new UserApiResponse();
+ try {
+ const res = await fetch(url.href, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ query
+ }),
+ });
+ if (res.ok) {
+ const result = await res.json();
+ status.responseData = result;
+ } else {
+ status.error = true;
+ }
+ } catch(err) {
+ status.error = true;
+ }
+ return status;
+}
diff --git a/src/Components/ShortcutKeyModal/SearchModal.css b/src/Components/ShortcutKeyModal/SearchModal.css
index ab072a626..02993f9cb 100644
--- a/src/Components/ShortcutKeyModal/SearchModal.css
+++ b/src/Components/ShortcutKeyModal/SearchModal.css
@@ -49,7 +49,7 @@
.shortcut-search-modal .hidden-tab {
font-size: medium;
- color: white;
+ color: lightgray;
}
/* Dark mode */
diff --git a/src/Components/ShortcutKeyModal/SearchModal.js b/src/Components/ShortcutKeyModal/SearchModal.js
index 9cbe23bbe..765b07032 100644
--- a/src/Components/ShortcutKeyModal/SearchModal.js
+++ b/src/Components/ShortcutKeyModal/SearchModal.js
@@ -4,8 +4,9 @@ import { officerOrAdminRoutes, signedOutRoutes, memberRoutes, notAuthenticatedRo
import { membershipState } from '../../Enums';
import { useUser } from '../context/UserContext';
import { useAuth } from '../context/AuthContext';
+import { searchAllUsers } from '../../APIFunctions/UserSearch';
-export default function SearchModal({ appProps }) {
+export default function SearchModal() {
const [open, setOpen] = useState(false);
const inputRef = useRef(null);
const modalRef = useRef(null);
@@ -15,18 +16,20 @@ export default function SearchModal({ appProps }) {
const { user } = useUser();
const [errorMsg, setErrorMsg] = useState('');
const { authenticated } = useAuth();
+ // Maximum number of suggestions to display in the search dropdown
+ const SHORTCUT_MAX_RESULT = 5;
/**
* Returns the appropriate routes array based on the user's access level.
- * @dependencies user.accessLevel, authenticated
+ * @dependencies user, authenticated
*/
const routes = useMemo(() => {
- if (user.accessLevel === membershipState.MEMBER)
+ if (user && user.accessLevel === membershipState.MEMBER)
return [
...memberRoutes.filter(r => r.pageName !== 'Edit User Info'),
...signedOutRoutes
];
- if (user.accessLevel >= membershipState.OFFICER)
+ if (user && user.accessLevel >= membershipState.OFFICER)
return [
...officerOrAdminRoutes.filter(r => r.pageName !== 'Edit User Info'),
...signedOutRoutes
@@ -37,7 +40,7 @@ export default function SearchModal({ appProps }) {
...signedOutRoutes
];
return [...signedOutRoutes];
- }, [user.accessLevel, authenticated]);
+ }, [user, authenticated]);
/**
* Helper function updates the keyword when the user types
@@ -57,7 +60,7 @@ export default function SearchModal({ appProps }) {
const SuggestionsList = () => {
if (suggestions.length === 0) return <>>;
- const topFiveItems = suggestions.slice(0, 5);
+ const topFiveItems = suggestions.slice(0, SHORTCUT_MAX_RESULT);
return (
{topFiveItems.map((r, index) => ( // Still keep index to keep track of the selected item
@@ -85,6 +88,32 @@ export default function SearchModal({ appProps }) {
);
};
+ /**
+ * Async function fetches all user data from the API
+ * @param {string} token - User's authentication token.
+ * @param {string} query - The search term.
+ */
+ const getUserData = async ({ token, query }) => {
+ try {
+ const apiResponse = await searchAllUsers({
+ token,
+ query
+ });
+
+ if (apiResponse.error || apiResponse.responseData.items.length === 0) return; // Exit early if there's an API error or an empty array
+
+ const userMatches = apiResponse.responseData.items
+ .map((u) => ({
+ pageName: `${u.firstName} ${u.lastName} (${u.email})`,
+ path: `/user/edit/${u._id}`,
+ type: 'user'
+ }));
+ setSuggestions(prev => [...prev, ...userMatches]);
+ } catch (error) {
+ setErrorMsg(error.message);
+ }
+ };
+
/**
* An effect that instantly shows all hardcoded routes.
* @dependencies keyword, routes, open
@@ -105,6 +134,27 @@ export default function SearchModal({ appProps }) {
setSuggestions(routeMatches);
}, [open, keyword, routes]);
+ /**
+ * A debounce function that performs the search 400ms after the user stops typing.
+ * @dependencies keyword, open, user.accessLevel
+ */
+ useEffect(() => {
+ if (!open ||
+ !user.accessLevel ||
+ user?.accessLevel < membershipState.OFFICER ||
+ !keyword) return;
+
+ const debounce = setTimeout(() => {
+ getUserData({
+ token: user.token,
+ query: keyword,
+ limit: SHORTCUT_MAX_RESULT
+ });
+ }, 400);
+
+ return () => clearTimeout(debounce);
+ }, [keyword, open, user.accessLevel]);
+
/**
* Executes a search when Enter is pressed
* @dependencies selectItem, suggestions
diff --git a/test/api/User.js b/test/api/User.js
index 25c0a8f8a..67f371f82 100644
--- a/test/api/User.js
+++ b/test/api/User.js
@@ -716,4 +716,198 @@ describe('User', () => {
});
});
});
+
+ describe('POST /shortcutsearchusers', () => {
+ const queryUser = { query: 'coOl' };
+ const fiveMatchUsers = { query: 'Lot' };
+ const url = '/api/user/shortcutsearchusers';
+
+ it('Should return status code 403 if no token is passed through', async () => {
+ setTokenStatus(false);
+ const result = await test.sendPostRequest(url, queryUser);
+ expect(result).to.have.status(FORBIDDEN);
+ });
+
+ it('Should return status code 401 if access level is invalid', async () => {
+ setTokenStatus(false, { accessLevel: MEMBERSHIP_STATE.MEMBER });
+ const result = await test.sendPostRequestWithToken(token, url, queryUser);
+ expect(result).to.have.status(UNAUTHORIZED);
+ });
+
+ before(async () => {
+ await User.deleteMany({});
+ const users = [
+ {
+ // contains 'lot'
+ firstName: 'Elton',
+ lastName: 'Salvatore',
+ email: 'test0@test.com',
+ password: 'Passw0rd',
+ emailVerified: true,
+ accessLevel: MEMBERSHIP_STATE.NON_MEMBER, // Not a Member
+ joinDate: new Date('2014-02-31'), // Previous Semester
+ membershipValidUntil: getMemberExpirationDate(0), // Expired
+ },
+ {
+ // contains both 'lot' and 'cool'
+ firstName: 'Lot',
+ lastName: 'IsCool',
+ email: 'test1@test.com',
+ password: 'Passw0rd',
+ emailVerified: true,
+ accessLevel: MEMBERSHIP_STATE.MEMBER,
+ joinDate: new Date('2015-03-01'), // This Semester
+ membershipValidUntil: getMemberExpirationDate(1), // Semester Plan
+ },
+ {
+ // contains 'lot'
+ firstName: 'Clinton',
+ lastName: 'Roberts',
+ email: 'test2@test.com',
+ password: 'Passw0rd',
+ emailVerified: true,
+ accessLevel: MEMBERSHIP_STATE.MEMBER,
+ joinDate: new Date('2014-09-10'), // Previous Semester
+ membershipValidUntil: getMemberExpirationDate(2), // Annual Plan
+ },
+ {
+ // contains both 'lot' and 'cool'
+ firstName: 'Lola',
+ lastName: 'Contetol',
+ email: 'test3@test.com',
+ password: 'Passw0rd',
+ emailVerified: true,
+ accessLevel: MEMBERSHIP_STATE.MEMBER,
+ joinDate: new Date('2015-03-10'), // This Semester
+ membershipValidUntil: getMemberExpirationDate(2) // Annual Plan
+ },
+ {
+ // contains 'lot'
+ firstName: 'Elton',
+ lastName: 'Salvatore',
+ email: 'test00@test.com',
+ password: 'Passw0rd',
+ emailVerified: true,
+ accessLevel: MEMBERSHIP_STATE.NON_MEMBER, // Not a Member
+ joinDate: new Date('2014-06-10'), // Previous Semester
+ membershipValidUntil: getMemberExpirationDate(0), // Expired
+ },
+ {
+ // contains 'lot'
+ firstName: 'Lori',
+ lastName: 'Mattingly',
+ email: 'test5@test.com',
+ password: 'Passw0rd',
+ emailVerified: true,
+ accessLevel: MEMBERSHIP_STATE.NON_MEMBER, // Not a Member
+ joinDate: new Date('2015-03-15'), // This Semester
+ // Expiration Date Unchanged
+ },
+ {
+ // contains 'lot'
+ firstName: 'Mallory',
+ lastName: 'Cotton',
+ email: 'test6@test.com',
+ password: 'Passw0rd',
+ emailVerified: true,
+ accessLevel: MEMBERSHIP_STATE.MEMBER,
+ joinDate: new Date('2015-03-05'), // This Semester
+ membershipValidUntil: getMemberExpirationDate(2), // Annual Plan
+ },
+ {
+ // contains 'cool'
+ firstName: 'Cool',
+ lastName: 'Unmatch',
+ email: 'test7@test.com',
+ password: 'Passw0rd',
+ emailVerified: true,
+ accessLevel: MEMBERSHIP_STATE.MEMBER,
+ joinDate: new Date('2015-02-15'), // This Semester
+ membershipValidUntil: getMemberExpirationDate(1), // Semester Plan
+ },
+ ];
+ await User.insertMany(users);
+ });
+
+ describe('When valid token and access level - status code 200', () => {
+ beforeEach(() => {
+ setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.OFFICER });
+ });
+
+ it('Should return an empty array when the query is missing', async () => {
+ const result = await test.sendPostRequestWithToken(token, url, {});
+ expect(result).to.have.status(OK);
+ expect(result.body).to.have.property('items').that.is.an('array').that.is.empty;
+ });
+
+ it('Should return FIVE records when query = \'Lot\'', async () => {
+ const result = await test.sendPostRequestWithToken(token, url, fiveMatchUsers);
+ expect(result).to.have.status(OK);
+ expect(result.body).to.have.property('items').that.is.an('array');
+ expect(result.body.items).to.have.lengthOf(5);
+ });
+
+ it('Should return no records when query = \'Pika\'', async () => {
+ const result = await test.sendPostRequestWithToken(token, url, { query: 'Pika' });
+ expect(result).to.have.status(OK);
+ expect(result.body).to.have.property('items').that.is.an('array').that.is.empty;
+ });
+
+ beforeEach(() => {
+ setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.ADMIN });
+ });
+
+ it('Should return THREE records when query = \'coOl\'', async () => {
+ const result = await test.sendPostRequestWithToken(token, url, queryUser);
+ expect(result).to.have.status(OK);
+ expect(result.body).to.have.property('items').that.is.an('array');
+ expect(result.body.items).to.have.lengthOf(3);
+ });
+
+ 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).to.have.property('items').that.is.an('array');
+ expect(result.body.items.map(u => u.email)).to.eql([
+ 'test1@test.com',
+ 'test0@test.com',
+ 'test00@test.com',
+ 'test2@test.com',
+ 'test3@test.com'
+ ]);
+ });
+ });
+
+ describe('When valid token and access level with injection-like input - status code 200', () => {
+ beforeEach(() => {
+ setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.ADMIN });
+ });
+ it('Should return AT MOST five records', async () => {
+ const injectionPayloads = [
+ '{"$gt": ""}',
+ '{"$where": "this.firstName === \'Mallory\'"}',
+ '{"$ne": null}',
+ '{"$or": [ {}, {} ]}',
+ '{"$regex": ".*" }',
+ '.*',
+ '^',
+ '[object Object]',
+ [],
+ {},
+ null,
+ undefined
+ ];
+ for (const payload of injectionPayloads) {
+ const result = await test.sendPostRequestWithToken(token, url, { query: String(payload)});
+ expect(result).to.have.status(OK);
+ expect(result.body).to.have.property('items').that.is.an('array');
+ expect(result.body.items.length).at.most(5);
+ }
+ });
+ });
+
+ after(() => {
+ User.deleteMany({});
+ });
+ });
});