import { ClientResponse } from '@sendgrid/client/src/response'; import { ResponseError } from '@sendgrid/helpers/classes'; import { NextApiRequest, NextApiResponse } from 'next'; import axios, { AxiosResponse, AxiosPromise } from 'axios'; import { PupilRequestEmail, ParentRequestEmail } from '@tutorbook/emails'; import { User, UserWithRoles, Appt, ApptJSON } from '@tutorbook/model'; import to from 'await-to-js'; import mail from '@sendgrid/mail'; import error from './helpers/error'; import { db, auth, DecodedIdToken, DocumentSnapshot, DocumentReference, } from './helpers/firebase'; mail.setApiKey(process.env.SENDGRID_API_KEY as string); interface BrambleRes { APImethod: string; status: string; result: string; } /** * Creates a new Bramble room using their REST API. * @see {@link https://about.bramble.io/api.html} */ function createBrambleRoom(appt: Appt): AxiosPromise { return axios({ method: 'post', url: 'https://api.bramble.io/createRoom', headers: { room: appt.id, agency: 'tutorbook', auth_token: process.env.BRAMBLE_API_KEY as string, }, }) as AxiosPromise; } /** * Sends out two types of emails: * 1. Email to the tutee attendees's parents asking for parental approval of the * tutoring match (the attendees are not sent the link to the Bramble room until * **after** we receive parental consent). * 2. Email to the tutee and mentee attendees letting them know that we've * received their request and are awaiting parental approval. * @todo Give the tutees an option to change their parent's contact info in * that second email (i.e. you might have entered fake stuff just to see search * results and now you can't do anything because those parental emails aren't * going where they should be). */ async function sendRequestEmails( request: Appt, attendees: ReadonlyArray ): Promise { await Promise.all( attendees.map(async (pupil: UserWithRoles) => { if (pupil.roles.indexOf('tutee') < 0 && pupil.roles.indexOf('mentee') < 0) return; if (pupil.roles.indexOf('tutee') >= 0) { await Promise.all( pupil.parents.map(async (parentUID: string) => { const parentDoc: DocumentSnapshot = await db .collection('users') .doc(parentUID) .get(); if (parentDoc.exists) { const parent: User = User.fromFirestore(parentDoc); /* eslint-disable @typescript-eslint/ban-types */ const [err] = await to< [ClientResponse, {}], Error | ResponseError >( /* eslint-enable @typescript-eslint/ban-types */ mail.send( new ParentRequestEmail(parent, pupil, request, attendees) ) ); if (err) { console.error( `[ERROR] ${err.name} sending ${parent.name} <${parent.email}> ` + `the parent pending lesson request (${ request.id as string }) email:`, err ); } else { console.log( `[DEBUG] Sent ${parent.name} <${parent.email}> the parent ` + `pending lesson request (${request.id as string}) email.` ); } } else { console.warn(`[WARNING] Parent (${parentUID}) did not exist.`); } }) ); } /* eslint-disable-next-line @typescript-eslint/ban-types */ const [err] = await to<[ClientResponse, {}], Error | ResponseError>( mail.send(new PupilRequestEmail(pupil, request, attendees)) ); if (err) { console.error( `[ERROR] ${err.name} sending ${pupil.name} <${pupil.email}> the ` + `pending request (${request.id as string}) email:`, err ); } else { console.log( `[DEBUG] Sent ${pupil.name} <${pupil.email}> the pending request ` + `(${request.id as string}) email.` ); } }) ); } export type CreateRequestRes = ApptJSON; /** * Takes an `ApptJSON` object, an authentication token, and: * 1. Performs the following verifications (sends a `400` error code and an * accompanying human-readable error message if any of them fail): * - Verifies the correct request body was sent (e.g. all parameters are there * and are all of the correct types). * - Verifies that the requested `Timeslot` is within all of the `attendee`'s * availability (by reading each `attendee`'s Firestore profile document). * Note that we **do not** throw an error if it is the request sender who * is unavailable. * - Verifies that the requested `subjects` are included in each of the * tutors' Firestore profile documents (where a tutor is defined as an * `attendee` whose `roles` include `tutor`). * - Verifies that the given `token` belongs to one of the `appt`'s * `attendees`. * 2. Creates [the Bramble tutoring lesson room]{@link https://about.bramble.io/api.html} * (so that the parent can preview the venue that their child will be using * to connect with their tutor). * 3. Creates a new `request` document containing the given `appt`'s data in the * pupil's (the owner of the given JWT `token`) Firestore sub-collections. * 4. Sends an email to the tutee's parent(s) asking for parental approval of * the tutoring match. * 5. Sends an email to the pupil (the sender of the lesson request) telling * them that we're awaiting parental approval. * * @param {ApptJSON} request - The appointment to create a pending request for. * The given `idToken` **must** be from one of the appointment's `attendees` * (see the above description for more requirements). * @return {ApptJSON} The created request (typically this is exactly the same as * the given `request` but it can be different if the server implements * different validations than the client). */ export default async function createRequest( req: NextApiRequest, res: NextApiResponse ): Promise { /* eslint-disable @typescript-eslint/no-unsafe-member-access */ // 1. Verify that the request body is valid. if (!req.body) { error(res, 'You must provide a request body.'); } else if (!req.body.subjects || !req.body.subjects.length) { error(res, 'Your appointment must contain valid subjects.'); } else if (!req.body.attendees || req.body.attendees.length < 2) { error(res, 'Your appointment must have >= 2 attendees.'); } else if (req.body.time && typeof req.body.time !== 'object') { error(res, 'Your appointment had an invalid time.'); } else if ( req.body.time && new Date(req.body.time.from).toString() === 'Invalid Date' ) { error(res, 'Your appointment had an invalid start time.'); } else if ( req.body.time && new Date(req.body.time.to).toString() === 'Invalid Date' ) { error(res, 'Your appointment had an invalid end time.'); } else if (!req.headers.authorization) { error(res, 'You must provide a valid Firebase Auth JWT.', 401); } else { const [err, token] = await to( auth.verifyIdToken(req.headers.authorization.replace('Bearer ', ''), true) ); if (err) { error(res, `Your Firebase Auth JWT is invalid: ${err.message}`, 401, err); } else { const request: Appt = Appt.fromJSON(req.body); const attendees: UserWithRoles[] = []; let attendeesIncludeAuthToken = false; let errored = false; await Promise.all( request.attendees.map(async (attendee) => { if (errored) return; // 1. Verify that the attendees have uIDs. if (!attendee.id) { error(res, 'All attendees must have valid uIDs.'); errored = true; return; } if (attendee.id === (token as DecodedIdToken).uid) { // 1. Verify that the appointment creator is an attendee. attendeesIncludeAuthToken = true; } const attendeeRef: DocumentReference = db .collection('users') .doc(attendee.id); const attendeeDoc: DocumentSnapshot = await attendeeRef.get(); // 1. Verify that the attendees exist. if (!attendeeDoc.exists) { error(res, `Attendee (${attendee.id}) does not exist.`); errored = true; return; } const user: User = User.fromFirestore(attendeeDoc); // 1. Verify that the attendees are available (note that we don't throw // an error if it is the request sender who is unavailable). if (request.time && !user.availability.contains(request.time)) { if (attendee.id === (token as DecodedIdToken).uid) { console.warn( `[WARNING] Sender is not available on ${request.time.toString()}.` ); } else { error( res, `${user.toString()} is not available on ${request.time.toString()}.` ); errored = true; return; } } // 1. Verify the tutors can teach the requested subjects. const isTutor: boolean = attendee.roles.indexOf('tutor') >= 0; const isMentor: boolean = attendee.roles.indexOf('mentor') >= 0; const canTutorSubject: (s: string) => boolean = (subject: string) => { return user.tutoring.subjects.includes(subject); }; const canMentorSubject: (s: string) => boolean = ( subject: string ) => { return user.mentoring.subjects.includes(subject); }; if (isTutor && !request.subjects.every(canTutorSubject)) { error(res, `${user.toString()} cannot tutor these subjects.`); errored = true; return; } if (isMentor && !request.subjects.every(canMentorSubject)) { error(res, `${user.toString()} cannot mentor these subjects.`); errored = true; return; } (user as UserWithRoles).roles = attendee.roles; attendees.push(user as UserWithRoles); }) ); if (errored) { // Don't do anything b/c we already sent an error code to the client. } else if (!attendeesIncludeAuthToken) { error(res, `Creator (${(token as DecodedIdToken).uid}) must attend.`); } else { // 2. Create a new Bramble room for the tutoring appointment. request.id = (attendees[0].ref as DocumentReference) .collection('requests') .doc().id; console.log(`[DEBUG] Creating Bramble room (${request.id})...`); const [brambleErr, brambleRes] = await to>( createBrambleRoom(request) ); if (brambleErr) { const msg = `${brambleErr.name} using Bramble: ${brambleErr.message}`; console.error(`[ERROR] ${msg}:`, brambleErr); error(res, msg, 500, brambleErr); } else { const brambleURL: string = (brambleRes as AxiosResponse) .data.result; request.venues.push({ type: 'bramble', url: brambleURL, description: `Join your tutoring lesson via this ` + 'Bramble room. Your room will be reused weekly until your ' + 'tutoring lesson is cancelled. To learn more about Bramble, ' + 'head over to their help center.', }); console.log(`[DEBUG] Creating pending request (${request.id})...`); await Promise.all( attendees.map(async (attendee: UserWithRoles) => { if ( attendee.roles.indexOf('tutee') < 0 && attendee.roles.indexOf('mentee') < 0 ) return; // 3. Create the appointment Firestore document. // TODO: Ensure that the `ref` property on this request points to // the correct document when creating the parent request emails. // It should point towards the document in the parent's child's // subcollections. Because we only support one-on-one tutoring // right now, that isn't a problem. But if there is more than one // tutee attendee, this will use the last one for the `ref`. request.ref = (attendee.ref as DocumentReference) .collection('requests') .doc(request.id as string); await request.ref.set(request.toFirestore()); }) ); // 4-5. Send out the pending request emails. await sendRequestEmails(request, attendees); res.status(201).json(request.toJSON()); console.log(`[DEBUG] Created pending request (${request.id}).`); } } } } /* eslint-enable @typescript-eslint/no-unsafe-member-access */ }