From 1b6f9e9c88ab1c2e0f3a2a74ae4ccc07bb449524 Mon Sep 17 00:00:00 2001 From: Aydan Pirani Date: Sun, 14 Jan 2024 17:35:39 -0800 Subject: [PATCH] Mail Service (#144) * Infra for mail service * Cleaned up code for registration * Registration fix * fixing docs * fix formatter --------- Co-authored-by: Lasya --- .test.env | 5 +- src/app.ts | 21 +- src/config.ts | 6 + src/database/registration-db.ts | 6 + src/formatTools.ts | 15 + src/services/admission/admission-router.ts | 2 +- src/services/mail/mail-formats.ts | 27 ++ src/services/mail/mail-lib.ts | 48 +++ src/services/mail/mail-router.ts | 31 ++ .../registration/registration-formats.ts | 26 +- .../registration/registration-router.ts | 301 +++++++++++++----- 11 files changed, 372 insertions(+), 116 deletions(-) create mode 100644 src/formatTools.ts create mode 100644 src/services/mail/mail-formats.ts create mode 100644 src/services/mail/mail-lib.ts create mode 100644 src/services/mail/mail-router.ts diff --git a/.test.env b/.test.env index 4bccc406..d7e6e78b 100644 --- a/.test.env +++ b/.test.env @@ -22,8 +22,9 @@ JWT_SECRET="123456789" # System administrators SYSTEM_ADMINS="admin" - S3_ACCESS_KEY="0123456789" S3_SECRET_KEY="0123456789" S3_REGION="us-west-1" -S3_BUCKET_NAME="s3_bucket_name" \ No newline at end of file +S3_BUCKET_NAME="s3_bucket_name" + +SPARKPOST_KEY = "123456789" diff --git a/src/app.ts b/src/app.ts index 173a21d5..d14ab71d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,17 +1,19 @@ import morgan from "morgan"; import express, { Application, Request, Response } from "express"; +import admissionRouter from "./services/admission/admission-router.js"; import authRouter from "./services/auth/auth-router.js"; -import userRouter from "./services/user/user-router.js"; import eventRouter from "./services/event/event-router.js"; +import mailRouter from "./services/mail/mail-router.js"; +import newsletterRouter from "./services/newsletter/newsletter-router.js"; import profileRouter from "./services/profile/profile-router.js"; import registrationRouter from "./services/registration/registration-router.js"; +import s3Router from "./services/s3/s3-router.js"; +import shopRouter from "./services/shop/shop-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 shopRouter from "./services/shop/shop-router.js"; -import s3Router from "./services/s3/s3-router.js"; +import userRouter from "./services/user/user-router.js"; + // import { InitializeConfigReader } from "./middleware/config-reader.js"; import { ErrorHandler } from "./middleware/error-handler.js"; import Models from "./database/models.js"; @@ -32,17 +34,18 @@ if (!Config.TEST) { app.use(express.json()); // Add routers for each sub-service +app.use("/admission/", admissionRouter); app.use("/auth/", authRouter); app.use("/event/", eventRouter); +app.use("/mail/", mailRouter); app.use("/newsletter/", newsletterRouter); app.use("/profile/", profileRouter); app.use("/registration/", registrationRouter); +app.use("/s3/", s3Router); +app.use("/shop/", shopRouter); app.use("/staff/", staffRouter); -app.use("/user/", userRouter); -app.use("/admission/", admissionRouter); app.use("/version/", versionRouter); -app.use("/shop/", shopRouter); -app.use("/s3/", s3Router); +app.use("/user/", userRouter); // Ensure that API is running app.get("/", (_: Request, res: Response) => { diff --git a/src/config.ts b/src/config.ts index c30d3fc2..e8ddc9ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,10 @@ export enum Device { CHALLENGE = "challenge", } +export enum RegistrationTemplates { + REGISTRATION_SUBMISSION = "registration_confirmation", +} + function requireEnv(name: string): string { const value = env[name]; @@ -55,6 +59,8 @@ const Config = { /* OAuth, Keys, & Permissions */ DB_URL: `mongodb+srv://${requireEnv("DB_USERNAME")}:${requireEnv("DB_PASSWORD")}@${requireEnv("DB_SERVER")}/`, + SPARKPOST_KEY: requireEnv("SPARKPOST_KEY"), + SPARKPOST_URL: "https://api.sparkpost.com/api/v1/transmissions?num_rcpt_errors=3", GITHUB_OAUTH_ID: requireEnv("GITHUB_OAUTH_ID"), GITHUB_OAUTH_SECRET: requireEnv("GITHUB_OAUTH_SECRET"), diff --git a/src/database/registration-db.ts b/src/database/registration-db.ts index 0ca99dd2..6f1d54e0 100644 --- a/src/database/registration-db.ts +++ b/src/database/registration-db.ts @@ -4,6 +4,9 @@ export class RegistrationApplication { @prop({ required: true }) public userId: string; + @prop({ required: true }) + public hasSubmitted: boolean; + @prop({ required: true }) public isProApplicant: boolean; @@ -33,6 +36,9 @@ export class RegistrationApplication { @prop({ required: true }) public location: string; + @prop({ required: true }) + public degree: string; + @prop({ required: true }) public university: string; diff --git a/src/formatTools.ts b/src/formatTools.ts new file mode 100644 index 00000000..0b260a65 --- /dev/null +++ b/src/formatTools.ts @@ -0,0 +1,15 @@ +export function isBoolean(value: unknown): boolean { + return typeof value === "boolean"; +} + +export function isNumber(value: unknown): boolean { + return typeof value === "number"; +} + +export function isString(value: unknown): boolean { + return typeof value === "string"; +} + +export function isArrayOfType(arr: unknown[], typeChecker: (value: unknown) => boolean): boolean { + return Array.isArray(arr) && arr.every(typeChecker); +} diff --git a/src/services/admission/admission-router.ts b/src/services/admission/admission-router.ts index 898f6fd2..82a93494 100644 --- a/src/services/admission/admission-router.ts +++ b/src/services/admission/admission-router.ts @@ -44,8 +44,8 @@ const admissionRouter: Router = Router(); * } * ] * @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. + * @apiError (500: Internal Server Error) {String} InternalError occurred on the server. * */ admissionRouter.get("/not-sent/", strongJwtVerification, async (_: Request, res: Response, next: NextFunction) => { const token: JwtPayload = res.locals.payload as JwtPayload; diff --git a/src/services/mail/mail-formats.ts b/src/services/mail/mail-formats.ts new file mode 100644 index 00000000..b5dbd790 --- /dev/null +++ b/src/services/mail/mail-formats.ts @@ -0,0 +1,27 @@ +import { isString, isArrayOfType } from "../../formatTools.js"; + +export interface MailInfoFormat { + templateId: string; + recipients: string[]; + scheduleTime?: string; +} + +export function isValidMailInfo(mailInfo: MailInfoFormat): boolean { + if (!mailInfo) { + return false; + } + + if (!isString(mailInfo.templateId)) { + return false; + } + + if (!isArrayOfType(mailInfo.recipients, isString)) { + return false; + } + + if (mailInfo.scheduleTime && !isString(mailInfo.scheduleTime)) { + return false; + } + + return true; +} diff --git a/src/services/mail/mail-lib.ts b/src/services/mail/mail-lib.ts new file mode 100644 index 00000000..172c5b7b --- /dev/null +++ b/src/services/mail/mail-lib.ts @@ -0,0 +1,48 @@ +import Config from "../../config.js"; +import axios, { AxiosResponse } from "axios"; +import { Response, NextFunction } from "express"; +import { StatusCode } from "status-code-enum"; +import { RouterError } from "../../middleware/error-handler.js"; +import { MailInfoFormat } from "./mail-formats.js"; + +export async function sendMailWrapper(res: Response, next: NextFunction, mailInfo: MailInfoFormat): Promise { + try { + const result = await sendMail(mailInfo.templateId, mailInfo.recipients, mailInfo.scheduleTime); + return res.status(StatusCode.SuccessOK).send(result.data); + } catch (error) { + return next( + new RouterError(StatusCode.ClientErrorBadRequest, "EmailNotSent", { + status: error.response?.status, + code: error.code, + }), + ); + } +} + +function sendMail(templateId: string, emails: string[], scheduleTime?: string): Promise { + const options = scheduleTime ? { start_time: scheduleTime } : {}; + const recipients = emails.map((emailAddress: string) => { + return { address: `${emailAddress}` }; + }); + + const data = { + options: options, + recipients: recipients, + content: { + template_id: templateId, + }, + }; + + const config = { + method: "post", + maxBodyLength: Infinity, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: Config.SPARKPOST_KEY, + }, + data: data, + }; + + return axios.post(Config.SPARKPOST_URL, data, config); +} diff --git a/src/services/mail/mail-router.ts b/src/services/mail/mail-router.ts new file mode 100644 index 00000000..7752422f --- /dev/null +++ b/src/services/mail/mail-router.ts @@ -0,0 +1,31 @@ +// POST /registration/ +// ➡️ send confirmation email to the email provided in application + +import { NextFunction, Request, Response, Router } from "express"; +import { strongJwtVerification } from "../../middleware/verify-jwt.js"; +import { RouterError } from "../../middleware/error-handler.js"; +import { StatusCode } from "status-code-enum"; +import { hasElevatedPerms } from "../auth/auth-lib.js"; +import { JwtPayload } from "../auth/auth-models.js"; +import { MailInfoFormat, isValidMailInfo } from "./mail-formats.js"; +import { sendMailWrapper } from "./mail-lib.js"; + +const mailRouter: Router = Router(); + +mailRouter.post("/send/", strongJwtVerification, async (req: Request, res: Response, next: NextFunction) => { + const payload: JwtPayload = res.locals.payload as JwtPayload; + + if (!hasElevatedPerms(payload)) { + return next(new RouterError(StatusCode.ClientErrorForbidden, "Forbidden")); + } + + const mailInfo = req.body as MailInfoFormat; + + if (!isValidMailInfo(mailInfo)) { + return next(new RouterError(StatusCode.ClientErrorBadRequest, "BadRequest")); + } + + return sendMailWrapper(res, next, mailInfo); +}); + +export default mailRouter; diff --git a/src/services/registration/registration-formats.ts b/src/services/registration/registration-formats.ts index baadba0b..44481554 100644 --- a/src/services/registration/registration-formats.ts +++ b/src/services/registration/registration-formats.ts @@ -1,4 +1,5 @@ import { Degree, Gender, HackInterest, HackOutreach, Race } from "./registration-models.js"; +import { isString, isBoolean, isArrayOfType, isNumber } from "../../formatTools.js"; export interface RegistrationFormat { userId: string; @@ -6,7 +7,7 @@ export interface RegistrationFormat { considerForGeneral?: boolean; preferredName: string; legalName: string; - email: string; + emailAddress: string; gender: Gender; race: Race[]; requestedTravelReimbursement: boolean; @@ -23,28 +24,17 @@ export interface RegistrationFormat { proEssay?: string; } -function isString(value: unknown): boolean { - return typeof value === "string"; -} - -function isBoolean(value: unknown): boolean { - return typeof value === "boolean"; -} - -function isNumber(value: unknown): boolean { - return typeof value === "number"; -} - -function isArrayOfType(arr: unknown[], typeChecker: (value: unknown) => boolean): boolean { - return Array.isArray(arr) && arr.every(typeChecker); -} - export function isValidRegistrationFormat(registration: RegistrationFormat): boolean { if (!registration) { return false; } - if (!isString(registration.preferredName) || !isString(registration.legalName) || !isString(registration.email)) { + if ( + !isString(registration.userId) || + !isString(registration.preferredName) || + !isString(registration.legalName) || + !isString(registration.emailAddress) + ) { return false; } diff --git a/src/services/registration/registration-router.ts b/src/services/registration/registration-router.ts index bc16ed61..4f0ca0cb 100644 --- a/src/services/registration/registration-router.ts +++ b/src/services/registration/registration-router.ts @@ -1,14 +1,23 @@ +import { StatusCode } from "status-code-enum"; +import { NextFunction } from "express-serve-static-core"; import { Request, Response, Router } from "express"; + +import { RegistrationTemplates } from "../../config.js"; import { strongJwtVerification } from "../../middleware/verify-jwt.js"; +import { RouterError } from "../../middleware/error-handler.js"; import Models from "../../database/models.js"; import { RegistrationApplication } from "../../database/registration-db.js"; +import { AdmissionDecision, DecisionStatus } from "../../database/admission-db.js"; + +import { Degree, Gender } from "./registration-models.js"; +import { RegistrationFormat, isValidRegistrationFormat } from "./registration-formats.js"; + import { hasElevatedPerms } from "../auth/auth-lib.js"; import { JwtPayload } from "../auth/auth-models.js"; -import { RegistrationFormat, isValidRegistrationFormat } from "./registration-formats.js"; -import { StatusCode } from "status-code-enum"; -import { Degree, Gender } from "./registration-models.js"; +import { sendMailWrapper } from "../mail/mail-lib.js"; +import { MailInfoFormat } from "../mail/mail-formats.js"; const registrationRouter: Router = Router(); @@ -17,28 +26,49 @@ const registrationRouter: Router = Router(); * @apiGroup Registration * @apiDescription Gets registration data for the current user in the JWT token. * - * @apiSuccess (200: Success) {String} userId UserID - * @apiSuccess (200: Success) {String} preferredName User's preffered name - * @apiSuccess (200: Success) {String} userName User's online username - * @apiSuccess (200: Success) {String} resume A FILLER VALUE FOR NOW, WE NEED TO FIGURE OUT HOW TO STORE RESUMES - * @apiSuccess (200: Success) {String[]} essays User's essays - * + * @apiSuccess (200: Success) {String} preferredName Applicant's preffered name + * @apiSuccess (200: Success) {String} legalName Applicant's full legal name + * @apiSuccess (200: Success) {String} email Applicant's email + * @apiSuccess (200: Success) {String} hackEssay1 First required essay + * @apiSuccess (200: Success) {String} hackEssay2 Second required essay + * @apiSuccess (200: Success) {String} optionalEssay Space for applicant to share additional thoughts + * @apiSuccess (200: Success) {String} location Applicant's location + * @apiSuccess (200: Success) {String} gender Applicant's gender + * @apiSuccess (200: Success) {String} degree Applicant's pursued degree + * @apiSuccess (200: Success) {String} gradYear Applicant's graduation year + * @apiSuccess (200: Success) {Boolean} isProApplicant True/False indicating if they are a pro applicant + * @apiSuccess (200: Success) {String} proEssay Third essay (required for Knights, empty string for General) + * @apiSuccess (200: Success) {Boolean} considerForGeneral True/False indicating if pro attendee wants to be considered for general + * @apiSuccess (200: Success) {Boolean} requestedTravelReimbursement True/False indicating if applicant requested reimbursement + * @apiSuccess (200: Success) {String} dietaryRestrictions Attendee's restrictions, include provided options and append any custom restrictions as provided by attendee + * @apiSuccess (200: Success) {String[]} race True/False Attendee's race/ethnicity + * @apiSuccess (200: Success) {String[]} hackInterest What the attendee is interested in for the event (multi-select) + * @apiSuccess (200: Success) {String[]} hackOutreach How the attendee found us (multi-select) * @apiSuccessExample Example Success Response: * HTTP/1.1 200 OK * { - "userId": "user123", - "preferredName": "John", - "userName": "john21", - "resume": "john-doe-resume.pdf", - "essays": [ - "essay 1", - "essay 2" - ] - } - * @apiError (400: Bad Request) {String} User not found in Database - * @apiErrorExample Example Error Response: - * HTTP/1.1 400 Bad Request - * {"error": "UserNotFound"} + * @apiSuccess (200: Success) {String} userId Applicant's userId + * "userId":"user1234", + * "preferredName": "Ronakin", + * "legalName": "Ronakin Kanandini", + * "email": "rpak@gmail.org", + * "university": "University of Illinois Urbana-Champaign", + * "hackEssay1": "I love hack", + * "hackEssay2": "I love hack", + * "optionalEssay": "", + * "location": "Urbana", + * "gender": ["Prefer Not To Answer"], + * "degree": "Masters", + * "gradYear": 0, + * "isProApplicant": true, + * "proEssay": "I wanna be a Knight", + * "considerForGeneral": true, + * "requestedTravelReimbursement: false, + * "dietaryRestrictions": "Vegetarian", + * "race": "Prefer Not To Answer", + * "hackInterest": ["Mini-Event"], + * "hackOutreach": ["Instagram"] + * } */ registrationRouter.get("/", strongJwtVerification, async (_: Request, res: Response) => { const defaultResponse = { @@ -47,7 +77,7 @@ registrationRouter.get("/", strongJwtVerification, async (_: Request, res: Respo considerForGeneral: true, preferredName: "", legalName: "", - email: "", + emailAddress: "", gender: Gender.OTHER, race: [], requestedTravelReimbursement: false, @@ -75,44 +105,65 @@ registrationRouter.get("/", strongJwtVerification, async (_: Request, res: Respo * @apiGroup Registration * @apiDescription Gets registration data for a specific user, provided that the authenticated user has elevated perms * - * @apiSuccess (200: Success) {String} userId UserID - * @apiSuccess (200: Success) {String} preferredName User's preffered name - * @apiSuccess (200: Success) {String} userName User's online username - * @apiSuccess (200: Success) {String} resume A FILLER VALUE FOR NOW, WE NEED TO FIGURE OUT HOW TO STORE RESUMES - * @apiSuccess (200: Success) {String[]} essays User's essays - * + * @apiSuccess (200: Success) {String} userId Applicant's userId + * @apiSuccess (200: Success) {String} preferredName Applicant's preffered name + * @apiSuccess (200: Success) {String} legalName Applicant's full legal name + * @apiSuccess (200: Success) {String} email Applicant's email + * @apiSuccess (200: Success) {String} hackEssay1 First required essay + * @apiSuccess (200: Success) {String} hackEssay2 Second required essay + * @apiSuccess (200: Success) {String} optionalEssay Space for applicant to share additional thoughts + * @apiSuccess (200: Success) {String} location Applicant's location + * @apiSuccess (200: Success) {String} gender Applicant's gender + * @apiSuccess (200: Success) {String} degree Applicant's pursued degree + * @apiSuccess (200: Success) {String} gradYear Applicant's graduation year + * @apiSuccess (200: Success) {Boolean} isProApplicant True/False indicating if they are a pro applicant + * @apiSuccess (200: Success) {String} proEssay Third essay (required for Knights, empty string for General) + * @apiSuccess (200: Success) {Boolean} considerForGeneral True/False indicating if pro attendee wants to be considered for general + * @apiSuccess (200: Success) {Boolean} requestedTravelReimbursement True/False indicating if applicant requested reimbursement + * @apiSuccess (200: Success) {String} dietaryRestrictions Attendee's restrictions, include provided options and append any custom restrictions as provided by attendee + * @apiSuccess (200: Success) {String[]} race True/False Attendee's race/ethnicity + * @apiSuccess (200: Success) {String[]} hackInterest What the attendee is interested in for the event (multi-select) + * @apiSuccess (200: Success) {String[]} hackOutreach How the attendee found us (multi-select) + * * @apiSuccessExample Example Success Response: * HTTP/1.1 200 OK * { - "userId": "user123", - "preferredName": "John", - "userName": "john21", - "resume": "john-doe-resume.pdf", - "essays": [ - "essay 1", - "essay 2" - ] - } - * @apiError (400: Bad Request) {String} User not found in Database - * @apiErrorExample Example Error Response: - * HTTP/1.1 400 Bad Request - * {"error": "UserNotFound"} + * "userId":"user1234", + * "preferredName": "Ronakin", + * "legalName": "Ronakin Kanandini", + * "email": "rpak@gmail.org", + * "university": "University of Illinois Urbana-Champaign", + * "hackEssay1": "I love hack", + * "hackEssay2": "I love hack", + * "optionalEssay": "", + * "location": "Urbana", + * "gender": ["Prefer Not To Answer"], + * "degree": "Masters", + * "gradYear": 0, + * "isProApplicant": true, + * "proEssay": "I wanna be a Knight", + * "considerForGeneral": true, + * "requestedTravelReimbursement: false, + * "dietaryRestrictions": "Vegetarian", + * "race": "Prefer Not To Answer", + * "hackInterest": ["Mini-Event"], + * "hackOutreach": ["Instagram"] + * } + * @apiError (403: Forbidden) {String} Forbidden User doesn't have elevated permissions + * @apiError (404: Not Found) {String} UserNotFound User not found in database */ -registrationRouter.get("/:USERID", strongJwtVerification, async (req: Request, res: Response) => { +registrationRouter.get("/userid/:USERID", strongJwtVerification, async (req: Request, res: Response, next: NextFunction) => { const userId: string | undefined = req.params.USERID; const payload: JwtPayload = res.locals.payload as JwtPayload; - //Sends error if caller doesn't have elevated perms if (!hasElevatedPerms(payload)) { - // TODO: CALL ERROR HANDLER ROUTER - return res.status(StatusCode.ClientErrorForbidden).send({ error: "Forbidden" }); + return next(new RouterError(StatusCode.ClientErrorForbidden, "Forbidden")); } const registrationData: RegistrationApplication | null = await Models.RegistrationApplications.findOne({ userId: userId }); if (!registrationData) { - // TODO: CALL ERROR HANDLER ROUTER - return res.status(StatusCode.ClientErrorNotFound).send({ error: "UserNotFound" }); + return next(new RouterError(StatusCode.ClientErrorNotFound, "UserNotFound")); } return res.status(StatusCode.SuccessOK).send(registrationData); @@ -123,57 +174,93 @@ registrationRouter.get("/:USERID", strongJwtVerification, async (req: Request, r * @apiGroup Registration * @apiDescription Creates registration data for the current user * - * @apiBody {string} userId UserID - * @apiBody {string} preferredName User's preffered name - * @apiBody {string} userName User's online username - * @apiBody {string} resume A FILLER VALUE FOR NOW, WE NEED TO FIGURE OUT HOW TO STORE RESUMES - * @apiBody {string[]} essays User's essays + * @apiBody (200: Success) {String} preferredName Applicant's preffered name + * @apiBody (200: Success) {String} legalName Applicant's full legal name + * @apiBody (200: Success) {String} email Applicant's email + * @apiBody (200: Success) {String} hackEssay1 First required essay + * @apiBody (200: Success) {String} hackEssay2 Second required essay + * @apiBody (200: Success) {String} optionalEssay Space for applicant to share additional thoughts + * @apiBody (200: Success) {String} location Applicant's location + * @apiBody (200: Success) {String} gender Applicant's gender + * @apiBody (200: Success) {String} degree Applicant's pursued degree + * @apiBody (200: Success) {String} gradYear Applicant's graduation year + * @apiBody (200: Success) {Boolean} isProApplicant True/False indicating if they are a pro applicant + * @apiBody (200: Success) {String} proEssay Third essay (required for Knights, empty string for General) + * @apiBody (200: Success) {Boolean} considerForGeneral True/False indicating if pro attendee wants to be considered for general + * @apiBody (200: Success) {Boolean} requestedTravelReimbursement True/False indicating if applicant requested reimbursement + * @apiBody (200: Success) {String} dietaryRestrictions Attendee's restrictions, include provided options and append any custom restrictions as provided by attendee + * @apiBody (200: Success) {String[]} race True/False Attendee's race/ethnicity + * @apiBody (200: Success) {String[]} hackInterest What the attendee is interested in for the event (multi-select) + * @apiBody (200: Success) {String[]} hackOutreach How the attendee found us (multi-select) + * * @apiParamExample {json} Example Request: * { - "userId": "user123", - "preferredName": "John", - "userName": "john21", - "resume": "john-doe-resume.pdf", - "essays": ["essay 1", "essay 2"] + * "preferredName": "Ronakin", + * "legalName": "Ronakin Kanandini", + * "email": "rpak@gmail.org", + * "university": "University of Illinois Urbana-Champaign", + * "hackEssay1": "I love hack", + * "hackEssay2": "I love hack", + * "optionalEssay": "", + * "location": "Urbana", + * "gender": ["Prefer Not To Answer"], + * "degree": "Masters", + * "gradYear": 0, + * "isProApplicant": true, + * "proEssay": "I wanna be a Knight", + * "considerForGeneral": true, + * "requestedTravelReimbursement: false, + * "dietaryRestrictions": "Vegetarian", + * "race": "Prefer Not To Answer", + * "hackInterest": ["Mini-Event"], + * "hackOutreach": ["Instagram"] * } * - * @apiSuccess (200: Success) {string} newRegistrationInfo The newly created object in registration/info - * @apiSuccess (200: Success) {string} newRegistrationApplication The newly created object in registration/application + * @apiSuccess (200: Success) {json} json Returns the POSTed registration information for user * @apiSuccessExample Example Success Response: * HTTP/1.1 200 OK * { - "newRegistrationInfo": { - "userId": "user123", - "preferredName": "John", - "userName": "john21", - "_id": "655110b6e84015eeea310fe0" - }, - "newRegistrationApplication": { - "userId": "user123", - "resume": "john-doe-resume.pdf", - "essays": [ - "essay 1", - "essay 2" - ], - "_id": "655110b6e84015eeea310fe2" - } + "userId": "user123", + "preferredName": "Ronakin", + "legalName": "Ronakin Kanandini", + "email": "rpak@gmail.org", + "university": "University of Illinois Urbana-Champaign", + "hackEssay1": "I love hack", + "hackEssay2": "I love hack", + "optionalEssay": "I wanna be a Knight", + "location": "Urbana", + "gender": "Prefer Not To Answer", + "degree": "Masters", + "gradYear": 0, + "isProApplicant": true, + "proEssay": "I wanna be a Knight", + "considerForGeneral": true, + "requestedTravelReimbursement: false. + "dietaryRestrictions": "Vegetarian" + "race": "Prefer Not To Answer" + "hackInterest": "Mini-Event" + "hackOutreach": "Instagram" } * - * @apiError (400: Bad Request) {String} User already exists in Database - * @apiErrorExample Example Error Response: - * HTTP/1.1 400 Bad Request - * {"error": "UserAlreadyExists"} - * + * @apiError (400: Bad Request) {String} UserAlreadyExists User already exists in Database + * @apiError (422: Unprocessable Entity) {String} AlreadySubmitted User already submitted application (cannot POST more than once) + * @apiError (500: Internal Server Error) {String} InternalError Server-side error * @apiUse strongVerifyErrors */ -registrationRouter.post("/", strongJwtVerification, async (req: Request, res: Response) => { +registrationRouter.post("/", strongJwtVerification, async (req: Request, res: Response, next: NextFunction) => { const payload: JwtPayload = res.locals.payload as JwtPayload; const userId: string = payload.id; const registrationData: RegistrationFormat = req.body as RegistrationFormat; + registrationData.userId = userId; + if (!isValidRegistrationFormat(registrationData)) { - // TODO: CALL ERROR HANDLER ROUTER - return res.status(StatusCode.ClientErrorBadRequest).send({ error: "BadRequest" }); + return next(new RouterError(StatusCode.ClientErrorBadRequest, "BadRequest")); + } + + const registrationInfo: RegistrationApplication | null = await Models.RegistrationApplications.findOne({ userId: userId }); + if (registrationInfo?.hasSubmitted ?? false) { + return next(new RouterError(StatusCode.ClientErrorUnprocessableEntity, "AlreadySubmitted")); } const newRegistrationInfo: RegistrationApplication | null = await Models.RegistrationApplications.findOneAndReplace( @@ -183,12 +270,54 @@ registrationRouter.post("/", strongJwtVerification, async (req: Request, res: Re ); if (!newRegistrationInfo) { - // TODO: CALL ERROR HANDLER ROUTER - return res.status(StatusCode.ServerErrorInternal).send({ error: "InternalError" }); + return next(new RouterError(StatusCode.ServerErrorInternal, "InternalError")); } - // TODO: S3 API TO GENERATE RESUME LINK AND SEND IT OVER WITH THIS INFO return res.status(StatusCode.SuccessOK).send(newRegistrationInfo); }); +// THIS ENDPOINT SHOULD PERFORM ALL THE ACTIONS REQUIRED ONCE YOU SUBMIT REGISTRATION +registrationRouter.post("/submit/", strongJwtVerification, async (_: Request, res: Response, next: NextFunction) => { + const payload: JwtPayload = res.locals.payload as JwtPayload; + const userId: string = payload.id; + + const registrationInfo: RegistrationApplication | null = await Models.RegistrationApplications.findOne({ userId: userId }); + + if (!registrationInfo) { + return next(new RouterError(StatusCode.ClientErrorNotFound, "NoRegistrationInfo")); + } + + if (registrationInfo?.hasSubmitted ?? false) { + return next(new RouterError(StatusCode.ClientErrorUnprocessableEntity, "AlreadySubmitted")); + } + + const newRegistrationInfo: RegistrationApplication | null = await Models.RegistrationApplications.findOneAndUpdate( + { userId: userId }, + { hasSubmitted: true }, + { new: true }, + ); + if (!newRegistrationInfo) { + return next(new RouterError(StatusCode.ServerErrorInternal, "InternalError")); + } + + const admissionInfo: AdmissionDecision | null = await Models.AdmissionDecision.findOneAndUpdate( + { + userId: userId, + }, + { status: DecisionStatus.TBD }, + { upsert: true, new: true }, + ); + + if (!admissionInfo) { + return next(new RouterError(StatusCode.ServerErrorInternal, "InternalError")); + } + + // SEND SUCCESFUL REGISTRATION EMAIL + const mailInfo: MailInfoFormat = { + templateId: RegistrationTemplates.REGISTRATION_SUBMISSION, + recipients: [registrationInfo.emailAddress], + }; + return sendMailWrapper(res, next, mailInfo); +}); + export default registrationRouter;