diff --git a/src/app.ts b/src/app.ts index e5d76408..1b079761 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ import profileRouter from "./services/profile/profile-router.js"; import staffRouter from "./services/staff/staff-router.js"; import newsletterRouter from "./services/newsletter/newsletter-router.js"; import versionRouter from "./services/version/version-router.js"; +import admissionRouter from "./services/admission/admission-router.js"; import { InitializeConfigReader } from "./middleware/config-reader.js"; import Models from "./database/models.js"; @@ -37,6 +38,7 @@ app.use("/newsletter/", newsletterRouter); app.use("/profile/", profileRouter); app.use("/staff/", staffRouter); app.use("/user/", userRouter); +app.use("/admission/", admissionRouter); app.use("/version/", versionRouter); // Ensure that API is running diff --git a/src/database/decision-db.ts b/src/database/decision-db.ts index 15401147..7b88ea87 100644 --- a/src/database/decision-db.ts +++ b/src/database/decision-db.ts @@ -1,13 +1,13 @@ import { prop } from "@typegoose/typegoose"; -enum DecisionStatus { +export enum DecisionStatus { TBD = "TBD", ACCEPTED = "ACCEPTED", REJECTED = "REJECTED", WAITLISTED = "WAITLISTED", } -enum DecisionResponse { +export enum DecisionResponse { PENDING = "PENDING", ACCEPTED = "ACCEPTED", DECLINED = "DECLINED", @@ -22,6 +22,12 @@ export class DecisionInfo { @prop({ required: true }) public response: DecisionResponse; + + @prop({ required: true }) + public reviewer: string; + + @prop({ required: true }) + public emailSent: boolean; } export class DecisionEntry { diff --git a/src/services/admission/admission-formats.ts b/src/services/admission/admission-formats.ts new file mode 100644 index 00000000..8dcb5a1d --- /dev/null +++ b/src/services/admission/admission-formats.ts @@ -0,0 +1,6 @@ +import { DecisionStatus } from "database/decision-db.js"; + +export interface ApplicantDecisionFormat { + userId: string; + status: DecisionStatus; +} diff --git a/src/services/admission/admission-router.test.ts b/src/services/admission/admission-router.test.ts new file mode 100644 index 00000000..20b3db0f --- /dev/null +++ b/src/services/admission/admission-router.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import Models from "../../database/models.js"; +import { DecisionStatus, DecisionResponse } from "../../database/decision-db.js"; +import { getAsStaff, getAsUser, putAsStaff, putAsUser, TESTER } from "../../testTools.js"; +import { DecisionInfo } from "../../database/decision-db.js"; +import { StatusCode } from "status-code-enum"; +import { ApplicantDecisionFormat } from "./admission-formats.js"; + +const TESTER_USER = { + userId: TESTER.id, + status: DecisionStatus.ACCEPTED, + response: DecisionResponse.PENDING, + emailSent: false, + reviewer: "tester-reviewer", +} satisfies DecisionInfo; + +const OTHER_USER = { + userId: "other-user", + status: DecisionStatus.REJECTED, + response: DecisionResponse.DECLINED, + emailSent: true, + reviewer: "other-reviewer", +} satisfies DecisionInfo; + +const updateData = [ + { + userId: TESTER.id, + status: DecisionStatus.WAITLISTED, + }, + { + userId: "other-user", + status: DecisionStatus.ACCEPTED, + }, +] satisfies ApplicantDecisionFormat[]; + +beforeEach(async () => { + Models.initialize(); + await Models.DecisionInfo.create(TESTER_USER); + await Models.DecisionInfo.create(OTHER_USER); +}); + +describe("GET /admission", () => { + it("gives forbidden error for user without elevated perms", async () => { + const responseUser = await getAsUser("/admission/").expect(StatusCode.ClientErrorForbidden); + expect(JSON.parse(responseUser.text)).toHaveProperty("error", "Forbidden"); + }); + it("should return a list of applicants without email sent", async () => { + const response = await getAsStaff("/admission/").expect(StatusCode.SuccessOK); + expect(JSON.parse(response.text)).toMatchObject(expect.arrayContaining([expect.objectContaining(TESTER_USER)])); + }); +}); + +describe("PUT /admission", () => { + it("gives forbidden error for user without elevated perms", async () => { + const responseUser = await putAsUser("/admission/").send(updateData).expect(StatusCode.ClientErrorForbidden); + expect(JSON.parse(responseUser.text)).toHaveProperty("error", "Forbidden"); + }); + it("should update application status of applicants", async () => { + const response = await putAsStaff("/admission/").send(updateData).expect(StatusCode.SuccessOK); + expect(JSON.parse(response.text)).toHaveProperty("message", "StatusSuccess"); + const ops = updateData.map((entry) => { + return Models.DecisionInfo.findOne({ userId: entry.userId }); + }); + const retrievedEntries = await Promise.all(ops); + expect(retrievedEntries).toMatchObject( + expect.arrayContaining( + updateData.map((item) => { + return expect.objectContaining({ status: item.status, userId: item.userId }); + }), + ), + ); + }); +}); diff --git a/src/services/admission/admission-router.ts b/src/services/admission/admission-router.ts new file mode 100644 index 00000000..effd9be7 --- /dev/null +++ b/src/services/admission/admission-router.ts @@ -0,0 +1,110 @@ +import { Router, Request, Response } from "express"; +import { strongJwtVerification } from "../../middleware/verify-jwt.js"; + +import { JwtPayload } from "../auth/auth-models.js"; +import { DecisionInfo } from "../../database/decision-db.js"; +import Models from "../../database/models.js"; +import { hasElevatedPerms } from "../auth/auth-lib.js"; +import { ApplicantDecisionFormat } from "./admission-formats.js"; +import { StatusCode } from "status-code-enum"; + +const admissionRouter: Router = Router(); + +/** + * @api {get} /admission/ GET /admission/ + * @apiGroup Admission + * @apiDescription Gets all applicants who don't have an email sent + * + * @apiSuccess (200: Success) {Json} entries The list of applicants without email sent + * @apiSuccessExample Example Success Response (Staff POV) + * HTTP/1.1 200 OK + * [ + * { + * "userId": "user1", + * "status": "ACCEPTED", + * "response": "ACCEPTED", + * "reviewer": "reviewer1", + * "emailSent": false + * }, + * { + * "userId": "user3", + * "status": "WAITLISTED", + * "response": "PENDING", + * "reviewer": "reviewer1", + * "emailSent": false + * }, + * { + * "userId": "user4", + * "status": "WAITLISTED", + * "response": "PENDING", + * "reviewer": "reviewer1", + * "emailSent": false + * } + * ] + * @apiUser strongVerifyErrors + * @apiError (500: Internal Server Error) {String} InternalError occurred on the server. + * @apiError (403: Forbidden) {String} Forbidden API accessed by user without valid perms. + * */ +admissionRouter.get("/", strongJwtVerification, async (_: Request, res: Response) => { + const token: JwtPayload = res.locals.payload as JwtPayload; + if (!hasElevatedPerms(token)) { + return res.status(StatusCode.ClientErrorForbidden).send({ error: "Forbidden" }); + } + try { + const filteredEntries: DecisionInfo[] = await Models.DecisionInfo.find({ emailSent: false }); + return res.status(StatusCode.SuccessOK).send(filteredEntries); + } catch (error) { + console.error(error); + } + return res.status(StatusCode.ClientErrorBadRequest).send({ error: "InternalError" }); +}); +/** + * @api {put} /admission/ PUT /admission/ + * @apiGroup Admission + * @apiDescription Updates the admission status of all applicants + * + * @apiHeader {String} Authorization Admin or Staff JWT Token + * + * @apiBody {Json} entries List of Applicants whose status needs to be updated + * + * @apiParamExample Example Request (Staff): + * HTTP/1.1 PUT /admission/ + * [ + * { + * "userId": "user1", + * "status": "ACCEPTED" + * }, + * { + * "userId": "user2", + * "status": "REJECTED" + * }, + * { + * "userId": "user3", + * "status": "WAITLISTED" + * } + * ] + * + * @apiSuccess (200: Success) {String} StatusSuccess + * + * @apiUse strongVerifyErrors + * @apiError (500: Internal Server Error) {String} InternalError occurred on the server. + * @apiError (403: Forbidden) {String} Forbidden API accessed by user without valid perms. + * */ +admissionRouter.put("/", strongJwtVerification, async (req: Request, res: Response) => { + const token: JwtPayload = res.locals.payload as JwtPayload; + if (!hasElevatedPerms(token)) { + return res.status(StatusCode.ClientErrorForbidden).send({ error: "Forbidden" }); + } + const updateEntries: ApplicantDecisionFormat[] = req.body as ApplicantDecisionFormat[]; + const ops = updateEntries.map((entry) => { + return Models.DecisionInfo.findOneAndUpdate({ userId: entry.userId }, { $set: { status: entry.status } }); + }); + try { + await Promise.all(ops); + return res.status(StatusCode.SuccessOK).send({ message: "StatusSuccess" }); + } catch (error) { + console.log(error); + } + return res.status(StatusCode.ClientErrorBadRequest).send("InternalError"); +}); +export default admissionRouter;