From 32f67170487b44ace1b7dc125b4e8185962353a6 Mon Sep 17 00:00:00 2001 From: Lasya <44485522+lasyaneti@users.noreply.github.com> Date: Tue, 23 Jan 2024 11:05:26 -0600 Subject: [PATCH] RSVP, Status Update emails (#172) * support for RSVP decline, confirm, confirm reimburse * error handling * tmp * Co-authored-by: Aydan Pirani * status update emails added --- src/config.ts | 2 + .../admission/admission-router.test.ts | 81 ++++++++++++++- src/services/admission/admission-router.ts | 98 +++++++++++++++++-- src/services/registration/registration-lib.ts | 6 ++ 4 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 src/services/registration/registration-lib.ts diff --git a/src/config.ts b/src/config.ts index c38e29ef..c8ba0bd8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,8 @@ export enum RegistrationTemplates { REGISTRATION_SUBMISSION = "2024_registration_confirmation", STATUS_UPDATE = "2024_status_update", RSVP_CONFIRMATION = "2024_rsvp_confirmation", + RSVP_CONFIRMATION_WITH_REIMBURSE = "2024_rsvp_confirmation_reimburse", + RSVP_DECLINED = "2024_rsvp_declined", RSVP_REMINDER_1_WEEK = "2024_rsvp-reminder-1week", RSVP_REMINDER_1_DAY = "2024_rsvp-reminder", } diff --git a/src/services/admission/admission-router.test.ts b/src/services/admission/admission-router.test.ts index 4f60f674..b8db4c18 100644 --- a/src/services/admission/admission-router.test.ts +++ b/src/services/admission/admission-router.test.ts @@ -1,8 +1,14 @@ -import { beforeEach, describe, expect, it } from "@jest/globals"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import Models from "../../database/models.js"; import { DecisionStatus, DecisionResponse, AdmissionDecision } from "../../database/admission-db.js"; +import { RegistrationFormat } from "../registration/registration-formats.js"; +import { RegistrationTemplates } from "./../../config.js"; +import { Gender, Degree, Race, HackInterest, HackOutreach } from "../registration/registration-models.js"; import { getAsStaff, getAsUser, putAsStaff, putAsUser, getAsAttendee, putAsApplicant, TESTER } from "../../testTools.js"; import { StatusCode } from "status-code-enum"; +import type * as MailLib from "../../services/mail/mail-lib.js"; +import type { AxiosResponse } from "axios"; +import { MailInfoFormat } from "services/mail/mail-formats.js"; const TESTER_DECISION = { userId: TESTER.id, @@ -18,6 +24,28 @@ const OTHER_DECISION = { emailSent: true, } satisfies AdmissionDecision; +const TESTER_APPLICATION = { + isProApplicant: false, + userId: TESTER.id, + preferredName: TESTER.name, + legalName: TESTER.name, + emailAddress: TESTER.email, + university: "ap", + hackEssay1: "ap", + hackEssay2: "ap", + optionalEssay: "ap", + location: "ap", + gender: Gender.OTHER, + degree: Degree.BACHELORS, + major: "CS", + gradYear: 0, + requestedTravelReimbursement: false, + dietaryRestrictions: [], + race: [Race.NO_ANSWER], + hackInterest: [HackInterest.OTHER], + hackOutreach: [HackOutreach.OTHER], +} satisfies RegistrationFormat; + const updateRequest = [ { userId: TESTER.id, @@ -26,7 +54,7 @@ const updateRequest = [ emailSent: false, }, { - userId: "other-user", + userId: OTHER_DECISION.userId, status: DecisionStatus.ACCEPTED, response: DecisionResponse.PENDING, emailSent: false, @@ -36,6 +64,7 @@ const updateRequest = [ beforeEach(async () => { await Models.AdmissionDecision.create(TESTER_DECISION); await Models.AdmissionDecision.create(OTHER_DECISION); + await Models.RegistrationApplication.create(TESTER_APPLICATION); }); describe("GET /admission/notsent/", () => { @@ -49,7 +78,20 @@ describe("GET /admission/notsent/", () => { }); }); +function mockSendMail(): jest.SpiedFunction { + const mailLib = require("../../services/mail/mail-lib.js") as typeof MailLib; + return jest.spyOn(mailLib, "sendMail"); +} + describe("PUT /admission/update/", () => { + let sendMail: jest.SpiedFunction = undefined!; + + beforeEach(async () => { + // Mock successful send by default + sendMail = mockSendMail(); + sendMail.mockImplementation(async (_) => ({}) as AxiosResponse); + }); + it("gives forbidden error for user without elevated perms", async () => { const responseUser = await putAsUser("/admission/update/").send(updateRequest).expect(StatusCode.ClientErrorForbidden); expect(JSON.parse(responseUser.text)).toHaveProperty("error", "Forbidden"); @@ -61,6 +103,12 @@ describe("PUT /admission/update/", () => { const ops = updateRequest.map((entry) => Models.AdmissionDecision.findOne({ userId: entry.userId })); const retrievedEntries = await Promise.all(ops); + + expect(sendMail).toBeCalledWith({ + templateId: RegistrationTemplates.STATUS_UPDATE, + recipients: [], // empty because neither test case starts as status = TBD + } satisfies MailInfoFormat); + expect(retrievedEntries).toMatchObject( expect.arrayContaining( updateRequest.map((item) => expect.objectContaining({ status: item.status, userId: item.userId })), @@ -131,6 +179,14 @@ describe("GET /admission/rsvp/:USERID", () => { }); describe("PUT /admission/rsvp/accept", () => { + let sendMail: jest.SpiedFunction = undefined!; + + beforeEach(async () => { + // Mock successful send by default + sendMail = mockSendMail(); + sendMail.mockImplementation(async (_) => ({}) as AxiosResponse); + }); + it("returns UserNotFound for nonexistent user", async () => { await Models.AdmissionDecision.deleteOne({ userId: TESTER.id, @@ -145,6 +201,12 @@ describe("PUT /admission/rsvp/accept", () => { await putAsApplicant("/admission/rsvp/accept/").expect(StatusCode.SuccessOK); const stored = await Models.AdmissionDecision.findOne({ userId: TESTER.id }); + expect(sendMail).toBeCalledWith({ + templateId: RegistrationTemplates.RSVP_CONFIRMATION, + recipients: [TESTER_APPLICATION.emailAddress], + subs: { name: TESTER_APPLICATION.preferredName }, + } satisfies MailInfoFormat); + expect(stored).toMatchObject({ ...TESTER_DECISION, response: DecisionResponse.ACCEPTED, @@ -170,6 +232,14 @@ describe("PUT /admission/rsvp/accept", () => { }); describe("PUT /admission/rsvp/decline/", () => { + let sendMail: jest.SpiedFunction = undefined!; + + beforeEach(async () => { + // Mock successful send by default + sendMail = mockSendMail(); + sendMail.mockImplementation(async (_) => ({}) as AxiosResponse); + }); + it("returns UserNotFound for nonexistent user", async () => { await Models.AdmissionDecision.deleteOne({ userId: TESTER.id, @@ -180,10 +250,15 @@ describe("PUT /admission/rsvp/decline/", () => { expect(JSON.parse(response.text)).toHaveProperty("error", "UserNotFound"); }); - it("lets applicant accept accepted decision", async () => { + it("lets applicant decline accepted decision", async () => { await putAsApplicant("/admission/rsvp/decline/").expect(StatusCode.SuccessOK); const stored = await Models.AdmissionDecision.findOne({ userId: TESTER.id }); + expect(sendMail).toBeCalledWith({ + templateId: RegistrationTemplates.RSVP_DECLINED, + recipients: [TESTER_APPLICATION.emailAddress], + } satisfies MailInfoFormat); + expect(stored).toMatchObject({ ...TESTER_DECISION, response: DecisionResponse.DECLINED, diff --git a/src/services/admission/admission-router.ts b/src/services/admission/admission-router.ts index e2845488..a1216bcc 100644 --- a/src/services/admission/admission-router.ts +++ b/src/services/admission/admission-router.ts @@ -10,6 +10,10 @@ import { StatusCode } from "status-code-enum"; import { NextFunction } from "express-serve-static-core"; import { RouterError } from "../../middleware/error-handler.js"; import { performRSVP } from "./admission-lib.js"; +import { MailInfoFormat } from "../mail/mail-formats.js"; +import { RegistrationTemplates } from "../../config.js"; +import { getApplication } from "../registration/registration-lib.js"; +import { sendMail } from "../mail/mail-lib.js"; const admissionRouter: Router = Router(); @@ -79,7 +83,8 @@ admissionRouter.get("/notsent/", strongJwtVerification, async (_: Request, res: * } * * @apiUse strongVerifyErrors - * @apiError (409: Conflict) Failed because RSVP has already happened. + * @apiError (409: AlreadyRSVPed) {string} Failed because RSVP has already happened. + * @apiError (424: EmailFailed) {string} Failed because depencency (mail service) failed. */ admissionRouter.put("/rsvp/accept/", strongJwtVerification, async (_: Request, res: Response, next: NextFunction) => { const payload: JwtPayload = res.locals.payload as JwtPayload; @@ -103,11 +108,36 @@ admissionRouter.put("/rsvp/accept/", strongJwtVerification, async (_: Request, r const updatedDecision = await performRSVP(queryResult.userId, DecisionResponse.ACCEPTED); - if (updatedDecision) { - return res.status(StatusCode.SuccessOK).send(updatedDecision); - } else { + if (!updatedDecision) { return next(new RouterError()); } + + const application = await getApplication(queryResult.userId); + if (!application) { + return next(new RouterError(StatusCode.ClientErrorNotFound, "ApplicationNotFound")); + } + + let mailInfo: MailInfoFormat; + if (application.requestedTravelReimbursement && (queryResult.reimbursementValue ?? 0) > 0) { + mailInfo = { + templateId: RegistrationTemplates.RSVP_CONFIRMATION_WITH_REIMBURSE, + recipients: [application.emailAddress], + subs: { name: application.preferredName, amount: queryResult.reimbursementValue }, + }; + } else { + mailInfo = { + templateId: RegistrationTemplates.RSVP_CONFIRMATION, + recipients: [application.emailAddress], + subs: { name: application.preferredName }, + }; + } + + try { + await sendMail(mailInfo); + return res.status(StatusCode.SuccessOK).send(updatedDecision); + } catch (error) { + return res.status(StatusCode.ClientErrorFailedDependency).send("EmailFailed"); + } }); /** @@ -131,7 +161,10 @@ admissionRouter.put("/rsvp/accept/", strongJwtVerification, async (_: Request, r * } * * @apiUse strongVerifyErrors - * @apiError (409: Conflict) Failed because RSVP has already happened. + * @apiError (404: UserNotFound) {string} Failed because user not found. + * @apiError (404: ApplicationNotFound) {string} Failed because application not found. + * @apiError (409: AlreadyRSVPed) {string} Failed because RSVP has already happened. + * @apiError (424: EmailFailed) {string} Failed because depencency (mail service) failed. */ admissionRouter.put("/rsvp/decline/", strongJwtVerification, async (_: Request, res: Response, next: NextFunction) => { const payload: JwtPayload = res.locals.payload as JwtPayload; @@ -155,11 +188,26 @@ admissionRouter.put("/rsvp/decline/", strongJwtVerification, async (_: Request, const updatedDecision = await performRSVP(queryResult.userId, DecisionResponse.DECLINED); - if (updatedDecision) { - return res.status(StatusCode.SuccessOK).send(updatedDecision); - } else { + if (!updatedDecision) { return next(new RouterError()); } + + const application = await getApplication(queryResult.userId); + if (!application) { + return next(new RouterError(StatusCode.ClientErrorNotFound, "ApplicationNotFound")); + } + + const mailInfo: MailInfoFormat = { + templateId: RegistrationTemplates.RSVP_DECLINED, + recipients: [application.emailAddress], + }; + + try { + await sendMail(mailInfo); + return res.status(StatusCode.SuccessOK).send(updatedDecision); + } catch (error) { + return res.status(StatusCode.ClientErrorFailedDependency).send("EmailFailed"); + } }); /** @@ -211,16 +259,46 @@ admissionRouter.put("/update/", strongJwtVerification, async (req: Request, res: return next(new RouterError(StatusCode.ClientErrorBadRequest, "BadRequest")); } + // collect emails whose status changed from TBD -> NON-TBD + const recipients: string[] = []; + for (let i = 0; i < updateEntries.length; ++i) { + const existingDecision = await Models.AdmissionDecision.findOne({ userId: updateEntries[i]?.userId }); + if (existingDecision?.status === DecisionStatus.TBD && updateEntries[i]?.status !== DecisionStatus.TBD) { + const application = await getApplication(existingDecision?.userId); + if (!application) { + throw new RouterError(StatusCode.ClientErrorNotFound, "ApplicationNotFound"); + } + recipients.push(application.emailAddress); + } + } + const ops = updateEntries.map((entry) => Models.AdmissionDecision.findOneAndUpdate( { userId: entry.userId }, - { $set: { status: entry.status, admittedPro: entry.admittedPro, reimbursementValue: entry.reimbursementValue } }, + { + $set: { + status: entry.status, + admittedPro: entry.admittedPro, + emailSent: true, + reimbursementValue: entry.reimbursementValue, + }, + }, ), ); try { await Promise.all(ops); - return res.status(StatusCode.SuccessOK).send({ message: "StatusSuccess" }); + + const mailInfo: MailInfoFormat = { + templateId: RegistrationTemplates.STATUS_UPDATE, + recipients: recipients, + }; + try { + await sendMail(mailInfo); + return res.status(StatusCode.SuccessOK).send({ message: "StatusSuccess" }); + } catch (error) { + return res.status(StatusCode.ClientErrorFailedDependency).send("EmailFailed"); + } } catch (error) { return next(new RouterError(undefined, undefined, undefined, `${error}`)); } diff --git a/src/services/registration/registration-lib.ts b/src/services/registration/registration-lib.ts new file mode 100644 index 00000000..375e2ac3 --- /dev/null +++ b/src/services/registration/registration-lib.ts @@ -0,0 +1,6 @@ +import Models from "../../database/models.js"; +import { RegistrationApplication } from "../../database/registration-db.js"; + +export function getApplication(userId: string): Promise { + return Models.RegistrationApplication.findOne({ userId: userId }); +}