Skip to content

Commit

Permalink
Mail Service (#144)
Browse files Browse the repository at this point in the history
* Infra for mail service

* Cleaned up code for registration

* Registration fix

* fixing docs

* fix formatter

---------

Co-authored-by: Lasya <neti.lasya@gmail.com>
  • Loading branch information
AydanPirani and lasyaneti committed Jan 15, 2024
1 parent 7cc95ed commit 1b6f9e9
Show file tree
Hide file tree
Showing 11 changed files with 372 additions and 116 deletions.
5 changes: 3 additions & 2 deletions .test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
S3_BUCKET_NAME="s3_bucket_name"

SPARKPOST_KEY = "123456789"
21 changes: 12 additions & 9 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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"),
Expand Down
6 changes: 6 additions & 0 deletions src/database/registration-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
15 changes: 15 additions & 0 deletions src/formatTools.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion src/services/admission/admission-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions src/services/mail/mail-formats.ts
Original file line number Diff line number Diff line change
@@ -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;
}
48 changes: 48 additions & 0 deletions src/services/mail/mail-lib.ts
Original file line number Diff line number Diff line change
@@ -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<void | Response> {
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<AxiosResponse> {
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);
}
31 changes: 31 additions & 0 deletions src/services/mail/mail-router.ts
Original file line number Diff line number Diff line change
@@ -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;
26 changes: 8 additions & 18 deletions src/services/registration/registration-formats.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Degree, Gender, HackInterest, HackOutreach, Race } from "./registration-models.js";
import { isString, isBoolean, isArrayOfType, isNumber } from "../../formatTools.js";

export interface RegistrationFormat {
userId: string;
isProApplicant: boolean;
considerForGeneral?: boolean;
preferredName: string;
legalName: string;
email: string;
emailAddress: string;
gender: Gender;
race: Race[];
requestedTravelReimbursement: boolean;
Expand All @@ -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;
}

Expand Down
Loading

0 comments on commit 1b6f9e9

Please sign in to comment.