tutorbook
Version:
Web app connecting students with expert mentors and tutors.
207 lines (196 loc) • 8.38 kB
text/typescript
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<User[]> {
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<SearchResponse<SearchHit>>(
index.search('', options) as Promise<SearchResponse<SearchHit>>
);
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<ListUsersRes>
): Promise<void> {
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<DecodedIdToken>(
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);
}