Skip to content

Commit

Permalink
RSVP, Status Update emails (#172)
Browse files Browse the repository at this point in the history
* support for RSVP decline, confirm, confirm reimburse

* error handling

* tmp

* Co-authored-by: Aydan Pirani <aydanpirani@gmail.com>

* status update emails added
  • Loading branch information
lasyaneti authored Jan 23, 2024
1 parent e3a628b commit 32f6717
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 13 deletions.
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
81 changes: 78 additions & 3 deletions src/services/admission/admission-router.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -26,7 +54,7 @@ const updateRequest = [
emailSent: false,
},
{
userId: "other-user",
userId: OTHER_DECISION.userId,
status: DecisionStatus.ACCEPTED,
response: DecisionResponse.PENDING,
emailSent: false,
Expand All @@ -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/", () => {
Expand All @@ -49,7 +78,20 @@ describe("GET /admission/notsent/", () => {
});
});

function mockSendMail(): jest.SpiedFunction<typeof MailLib.sendMail> {
const mailLib = require("../../services/mail/mail-lib.js") as typeof MailLib;
return jest.spyOn(mailLib, "sendMail");
}

describe("PUT /admission/update/", () => {
let sendMail: jest.SpiedFunction<typeof MailLib.sendMail> = 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");
Expand All @@ -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 })),
Expand Down Expand Up @@ -131,6 +179,14 @@ describe("GET /admission/rsvp/:USERID", () => {
});

describe("PUT /admission/rsvp/accept", () => {
let sendMail: jest.SpiedFunction<typeof MailLib.sendMail> = 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,
Expand All @@ -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,
Expand All @@ -170,6 +232,14 @@ describe("PUT /admission/rsvp/accept", () => {
});

describe("PUT /admission/rsvp/decline/", () => {
let sendMail: jest.SpiedFunction<typeof MailLib.sendMail> = 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,
Expand All @@ -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,
Expand Down
98 changes: 88 additions & 10 deletions src/services/admission/admission-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand All @@ -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");
}
});

/**
Expand All @@ -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;
Expand All @@ -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");
}
});

/**
Expand Down Expand Up @@ -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}`));
}
Expand Down
6 changes: 6 additions & 0 deletions src/services/registration/registration-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Models from "../../database/models.js";
import { RegistrationApplication } from "../../database/registration-db.js";

export function getApplication(userId: string): Promise<RegistrationApplication | null> {
return Models.RegistrationApplication.findOne({ userId: userId });
}

0 comments on commit 32f6717

Please sign in to comment.