Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mail Service #144

Merged
merged 5 commits into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
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);
}
39 changes: 39 additions & 0 deletions src/services/mail/mail-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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";

// PUT /admission/
lasyaneti marked this conversation as resolved.
Show resolved Hide resolved
// ➡️ email everyone that had a decision changed from PENDING to ACCEPTED/WAITLISTED/REJECTED

// ➡️ periodically email those who were ACCEPTED but did not respond to RSVP yet (maybe 2 reminders max)

// PUT /admission/rsvp/
// ➡️ auto email confirmation to user that updated their rsvp choice (userid in JWT)

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
Loading