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 (