Skip to content
Merged
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
91 changes: 91 additions & 0 deletions api/main_endpoints/routes/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
39 changes: 39 additions & 0 deletions src/APIFunctions/UserSearch.js
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion src/Components/ShortcutKeyModal/SearchModal.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

.shortcut-search-modal .hidden-tab {
font-size: medium;
color: white;
color: lightgray;
}

/* Dark mode */
Expand Down
62 changes: 56 additions & 6 deletions src/Components/ShortcutKeyModal/SearchModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 (
<ul className='suggestion-list'>
{topFiveItems.map((r, index) => ( // Still keep index to keep track of the selected item
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading