import { NextApiRequest, NextApiResponse } from 'next'; import algoliasearch, { SearchClient, SearchIndex } from 'algoliasearch/lite'; import { SearchOptions, SearchResponse } from '@algolia/client-search'; import { Org, User, UserJSON, SearchHit, Query, Timeslot, } from '@tutorbook/model'; import to from 'await-to-js'; import { db, auth, DecodedIdToken, DocumentSnapshot } from './helpers/firebase'; const algoliaId: string = process.env.ALGOLIA_SEARCH_ID as string; const algoliaKey: string = process.env.ALGOLIA_SEARCH_KEY as string; const client: SearchClient = algoliasearch(algoliaId, algoliaKey); const index: SearchIndex = client.initIndex( process.env.NODE_ENV === 'development' ? 'test-users' : 'default-users' ); /** * Creates and returns the filter string to search our Algolia index based on * `this.props.filters`. Note that due to Algolia restrictions, we **cannot** * nest ANDs with ORs (e.g. `(A AND B) OR (B AND C)`). Because of this * limitation, we merge results from many queries on the client side (e.g. get * results for `A AND B` and merge them with the results for `B AND C`). * @example * '(tutoring.subjects:"Chemistry H" OR tutoring.subjects:"Chemistry") AND ' + * '((availability.from <= 1587304800001 AND availability.to >= 1587322800000))' * @see {@link https://www.algolia.com/doc/guides/managing-results/refine-results/filtering/how-to/filter-by-date/?language=javascript} * @see {@link https://www.algolia.com/doc/guides/managing-results/refine-results/filtering/in-depth/combining-boolean-operators/#combining-ands-and-ors} * @see {@link https://www.algolia.com/doc/guides/managing-results/refine-results/filtering/how-to/filter-arrays/?language=javascript} */ function getFilterStrings(query: Query): string[] { let filterString = ''; for (let i = 0; i < query.subjects.length; i += 1) { filterString += i === 0 ? '(' : ' OR '; filterString += `${query.aspect}.subjects:"${query.subjects[i].value}"`; if (i === query.subjects.length - 1) filterString += ')'; } if (query.langs.length && query.subjects.length) filterString += ' AND '; for (let i = 0; i < query.langs.length; i += 1) { filterString += i === 0 ? '(' : ' OR '; filterString += `langs:"${query.langs[i].value}"`; if (i === query.langs.length - 1) filterString += ')'; } if ( (query.checks.length && query.langs.length) || (query.checks.length && query.subjects.length) ) filterString += ' AND '; for (let i = 0; i < query.checks.length; i += 1) { filterString += i === 0 ? '(' : ' AND '; filterString += `verifications.checks:"${query.checks[i].value}"`; if (i === query.checks.length - 1) filterString += ')'; } if ( (query.orgs.length && query.langs.length) || (query.orgs.length && query.subjects.length) || (query.orgs.length && query.checks.length) ) filterString += ' AND '; for (let i = 0; i < query.orgs.length; i += 1) { filterString += i === 0 ? '(' : ' OR '; filterString += `orgs:"${query.orgs[i].value}"`; if (i === query.orgs.length - 1) filterString += ')'; } if ( (query.availability.length && query.langs.length) || (query.availability.length && query.subjects.length) || (query.availability.length && query.checks.length) || (query.availability.length && query.orgs.length) ) filterString += ' AND '; const filterStrings: string[] = []; query.availability.forEach((timeslot: Timeslot) => filterStrings.push( `${filterString}(availability.from <= ${timeslot.from.valueOf()}` + ` AND availability.to >= ${timeslot.to.valueOf()})` ) ); if (!query.availability.length) filterStrings.push(filterString); return filterStrings; } /** * This is our way of showing the most relevant search results first without * paying for Algolia's visual editor. * @todo Show verified results first. * @see {@link https://www.algolia.com/doc/guides/managing-results/rules/merchandising-and-promoting/how-to/how-to-promote-with-optional-filters/} */ function getOptionalFilterStrings(query: Query): string[] { return [`featured:${query.aspect}`]; } /** * Searches users based on the current filters by querying like: * > Show me all users whose availability contains a timeslot whose open time * > is equal to or before the desired open time and whose close time is equal * > to or after the desired close time. * Note that due to Algolia limitations, we must query for each availability * timeslot separately and then manually merge the results on the client side. */ async function searchUsers(query: Query): Promise { const results: User[] = []; let filterStrings: (string | undefined)[] = getFilterStrings(query); if (!filterStrings.length) filterStrings = [undefined]; const optionalFilters: string[] = getOptionalFilterStrings(query); console.log('[DEBUG] Filtering by:', { filterStrings, optionalFilters }); await Promise.all( filterStrings.map(async (filterString) => { const options: SearchOptions | undefined = filterString ? { optionalFilters, filters: filterString } : { optionalFilters }; const [err, res] = await to>( index.search('', options) as Promise> ); if (err || !res) { /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */ console.error(`[ERROR] While searching ${filterString}:`, err); } else { res.hits.forEach((hit: SearchHit) => { if (results.findIndex((h) => h.id === hit.objectID) < 0) results.push(User.fromSearchHit(hit)); }); } }) ); return results; } /** * For privacy reasons, we only add the user's first name and last initial to * our Algolia search index (and thus we **never** share the user's full name). * @example * assert(onlyFirstNameAndLastInitial('Nicholas Chiang') === 'Nicholas C.'); * @todo Avoid code duplication from `algoliaUserUpdate` Firebase Function. */ function onlyFirstNameAndLastInitial(name: string): string { const split: string[] = name.split(' '); return `${split[0]} ${split[split.length - 1][0]}.`; } export type ListUsersRes = UserJSON[]; /** * Takes filter parameters (subjects and availability) and sends back an array * of `SearchResult`s that match the given filters. * * Note that we only send non-sensitive user information back to the client: * - User's first name and last initial * - User's bio (e.g. their education and experience) * - User's availability (for tutoring) * - User's subjects (what they can tutor) * - User's searches (what they need tutoring for) * - User's Firebase Authentication uID (as the Algolia `objectID`) * * We send full data back to client if and only if that data is owned by the * client's organization (i.e. the JWT sent belongs to a user whose a member of * an organization listed in the result's `orgs` field). */ export default async function listUsers( req: NextApiRequest, res: NextApiResponse ): Promise { console.log('[DEBUG] Getting search results...'); const users: User[] = await searchUsers(Query.fromURLParams(req.query)); const orgs: Org[] = []; if (req.headers.authorization) { const [err, token] = await to( auth.verifyIdToken(req.headers.authorization.replace('Bearer ', ''), true) ); if (err) { console.warn('[WARNING] Firebase Authorization JWT invalid:', err); } else { ( await db .collection('orgs') .where('members', 'array-contains', (token as DecodedIdToken).uid) .get() ).forEach((org: DocumentSnapshot) => orgs.push(Org.fromFirestore(org))); } } const results: UserJSON[] = users.map((user: User) => { const truncated: UserJSON = { name: onlyFirstNameAndLastInitial(user.name), photo: user.photo, bio: user.bio, orgs: user.orgs, availability: user.availability.toJSON(), mentoring: user.mentoring, tutoring: user.tutoring, socials: user.socials, langs: user.langs, id: user.id, } as UserJSON; if (orgs.every((org) => user.orgs.indexOf(org.id) < 0)) return truncated; return user.toJSON(); }); console.log(`[DEBUG] Got ${results.length} results.`); res.status(200).json(results); }