diff --git a/common/src/types.ts b/common/src/types.ts index a3d38fc..1fdc256 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -89,4 +89,11 @@ export type AutoPopulatedDoc = PopulatedType & { _id: mongoose.Re * All the types of actions that can be performed on the system. Used for @casl/ability * for managing permissions. */ -export type AbilityAction = "read" | "create" | "update" | "delete" | "manage" | "aggregate"; +export type AbilityAction = + | "read" + | "create" + | "update" + | "delete" + | "manage" + | "aggregate" + | "refer"; diff --git a/services/registration/src/common/util.ts b/services/registration/src/common/util.ts index 3533e5d..fba5049 100644 --- a/services/registration/src/common/util.ts +++ b/services/registration/src/common/util.ts @@ -40,6 +40,73 @@ export const validateApplicationData = async (data: any, branchId: any, branchFo throw new BadRequestError(JSON.stringify(validate.errors, null, 4)); }; +const referralJsonSchema = { + type: "object", + required: [ + "firstName", + "lastName", + "email", + "school", + "referForReimbursement", + "referForEarlyApplication", + "resume", + "essay", + ], + properties: { + firstName: { + type: "string", + minLength: 1, + }, + lastName: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + format: "email", + }, + phoneNumber: { + type: "string", + }, + school: { + type: "string", + minLength: 1, + }, + schoolYear: { + type: "string", + }, + referForReimbursement: { + type: "boolean", + }, + referForEarlyApplication: { + type: "boolean", + }, + resume: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + minLength: 1, + }, + }, + }, + essay: { + type: "string", + minLength: 10, + }, + }, +}; + +export const validateReferralData = async (data: any) => { + const validate = ajv.compile(referralJsonSchema); + const valid = validate(data); + if (valid || validate.errors?.length === 0) { + return; + } + throw new BadRequestError(JSON.stringify(validate.errors, null, 4)); +}; + /** * Gets the correct branch (application or confirmation) based on the request body * @param existingApplication the existing application diff --git a/services/registration/src/models/referral.ts b/services/registration/src/models/referral.ts new file mode 100644 index 0000000..2b2eafc --- /dev/null +++ b/services/registration/src/models/referral.ts @@ -0,0 +1,112 @@ +import { AccessibleRecordModel, accessibleRecordsPlugin } from "@casl/mongoose"; +import mongoose, { model, Schema, Types } from "mongoose"; + +export enum ReferralStatusType { + DRAFT = "DRAFT", + SUBMITTED = "SUBMITTED", +} + +export interface Referral extends mongoose.Document { + referrerId: string; + referrerName: string; + referrerEmail: string; + hexathon: Types.ObjectId; + referralData: { + firstName?: string; + lastName?: string; + email?: string; + phoneNumber?: string; + school?: string; + schoolYear?: string; + referForReimbursement?: boolean; + referForEarlyApplication?: boolean; + resume?: Types.ObjectId; + essay?: string; + }; + referralStartTime: Date; + referralSubmitTime?: Date; + status: ReferralStatusType; + createdAt: Date; + updatedAt: Date; +} + +const referralSchema = new Schema( + { + referrerId: { + type: String, + required: true, + index: true, + }, + referrerName: { + type: String, + required: true, + index: true, + }, + referrerEmail: { + type: String, + required: true, + index: true, + }, + hexathon: { + type: Schema.Types.ObjectId, + required: true, + index: true, + }, + referralData: { + firstName: { + type: String, + }, + lastName: { + type: String, + }, + email: { + type: String, + }, + phoneNumber: { + type: String, + }, + school: { + type: String, + }, + schoolYear: { + type: String, + }, + referForReimbursement: { + type: Boolean, + }, + referForEarlyApplication: { + type: Boolean, + }, + resume: { + type: Schema.Types.ObjectId, + }, + essay: { + type: String, + }, + }, + referralStartTime: { + type: Date, + required: true, + }, + referralSubmitTime: { + type: Date, + }, + status: { + type: String, + required: true, + default: ReferralStatusType.DRAFT, + enum: ReferralStatusType, + index: true, + }, + }, + { + timestamps: true, + } +); + +referralSchema.plugin(accessibleRecordsPlugin); + +export const ReferralModel = model>( + "Referral", + referralSchema +); diff --git a/services/registration/src/permission.ts b/services/registration/src/permission.ts index 748591b..1a34d29 100644 --- a/services/registration/src/permission.ts +++ b/services/registration/src/permission.ts @@ -15,6 +15,7 @@ export const addAbilities = (): RequestHandler => (req, res, next) => { can("manage", "Branch"); can("manage", "Grader"); can("manage", "Review"); + can("manage", "Referral"); } if (req.user.roles.member) { @@ -23,6 +24,9 @@ export const addAbilities = (): RequestHandler => (req, res, next) => { can("read", "Review"); can("manage", "Review", { reviewerId: req.user.uid }); can("manage", "Email"); + can("read", "Referral"); + can("create", "Referral"); + can("update", "Referral", { referrerId: req.user.uid }); } if (req.user.roles.admin || req.user.roles.member) { diff --git a/services/registration/src/routes/index.ts b/services/registration/src/routes/index.ts index eb7da69..6df6e76 100644 --- a/services/registration/src/routes/index.ts +++ b/services/registration/src/routes/index.ts @@ -4,6 +4,7 @@ import { applicationRouter } from "./application"; import { branchRouter } from "./branch"; import { emailRouter } from "./email"; import { gradingRouter } from "./grading"; +import { referralRouter } from "./referrals"; import { statisticsRouter } from "./statistics"; export const defaultRouter = express.Router(); @@ -13,3 +14,4 @@ defaultRouter.use("/applications", applicationRouter); defaultRouter.use("/statistics", statisticsRouter); defaultRouter.use("/grading", gradingRouter); defaultRouter.use("/email", emailRouter); +defaultRouter.use("/referrals", referralRouter); diff --git a/services/registration/src/routes/referrals.ts b/services/registration/src/routes/referrals.ts new file mode 100644 index 0000000..905bae5 --- /dev/null +++ b/services/registration/src/routes/referrals.ts @@ -0,0 +1,286 @@ +import { apiCall, asyncHandler, BadRequestError, checkAbility, getFullName } from "@api/common"; +import { Service } from "@api/config"; +import express from "express"; +import _ from "lodash"; +import { FilterQuery, isValidObjectId, Types } from "mongoose"; + +import { validateReferralData } from "../common/util"; +import { Referral, ReferralModel, ReferralStatusType } from "../models/referral"; + +export const referralRouter = express.Router(); + +referralRouter.route("/actions/create-referral").post( + checkAbility("create", "Referral"), + asyncHandler(async (req, res) => { + if (!req.body.hexathon) { + throw new BadRequestError("Hexathon is required."); + } + + const userInfo = await apiCall( + Service.USERS, + { method: "GET", url: `users/${req.user?.uid}` }, + req + ); + + if (!userInfo || Object.keys(userInfo).length === 0) { + throw new BadRequestError("User not found."); + } + + const newReferral = await ReferralModel.create({ + referrerId: req.user?.uid, + referrerName: getFullName(userInfo.name), + referrerEmail: userInfo.email, + hexathon: req.body.hexathon, + referralData: {}, + referralStartTime: new Date(), + status: ReferralStatusType.DRAFT, + }); + + return res.status(200).send(newReferral); + }) +); + +referralRouter.route("/").get( + checkAbility("read", "Referral"), + asyncHandler(async (req, res) => { + if (!req.query.hexathon) { + throw new BadRequestError("Hexathon filter is required"); + } + + const filter: FilterQuery = { + hexathon: req.query.hexathon, + }; + + if (req.query.mine === "true") { + filter.referrerId = req.user?.uid; + } + + if (req.query.status?.length) { + filter.status = req.query.status; + } + + if (req.query.search && typeof req.query.search === "string") { + const search = req.query.search.trim().slice(0, 75); + if (search) { + const sanitizedSearch = _.escapeRegExp(search); + const searchRegex = new RegExp(sanitizedSearch, "i"); + const searchFilters: FilterQuery[] = [ + { referrerId: { $regex: searchRegex } }, + { referrerName: { $regex: searchRegex } }, + { referrerEmail: { $regex: searchRegex } }, + { "referralData.firstName": { $regex: searchRegex } }, + { "referralData.lastName": { $regex: searchRegex } }, + { "referralData.email": { $regex: searchRegex } }, + { "referralData.school": { $regex: searchRegex } }, + ]; + + if (isValidObjectId(search)) { + searchFilters.unshift({ _id: new Types.ObjectId(search) }); + } + + filter.$or = searchFilters; + } + } + + const matchCount = await ReferralModel.accessibleBy(req.ability).find(filter).count(); + + const limit = parseInt(req.query.limit as string) || 50; + const offset = parseInt(req.query.offset as string) || 0; + + const referrals = await ReferralModel.accessibleBy(req.ability) + .find(filter) + .skip(offset) + .limit(limit); + + return res.status(200).json({ + offset, + total: matchCount, + count: referrals.length, + referrals, + }); + }) +); + +referralRouter.route("/:id").get( + checkAbility("read", "Referral"), + asyncHandler(async (req, res) => { + const referral = await ReferralModel.findById(req.params.id).accessibleBy(req.ability); + + if (!referral) { + throw new BadRequestError("Referral not found or you do not have permission to access."); + } + + const referralData = { ...referral.referralData } as any; + if (referralData.resume) { + referralData.resume = await apiCall( + Service.FILES, + { + method: "GET", + url: `files/${referralData.resume}`, + params: { + hexathon: referral.hexathon.toString(), + }, + }, + req + ); + } + + return res.send({ + ...referral.toJSON(), + referralData, + }); + }) +); + +referralRouter.route("/:id").delete( + asyncHandler(async (req, res) => { + const referral = await ReferralModel.findById(req.params.id).accessibleBy(req.ability); + + if (!referral) { + throw new BadRequestError("Referral not found or you do not have permission to delete."); + } + + if (referral.referrerId !== req.user?.uid && !req.user?.roles.admin) { + throw new BadRequestError("You do not have permission to delete this referral."); + } + + /* + if (referral.status !== ReferralStatusType.DRAFT) { + throw new BadRequestError("You can only delete a referral if the status is a draft."); + } + */ + + await ReferralModel.findByIdAndDelete(req.params.id); + return res.json({ message: "Referral deleted successfully." }); + }) +); + +referralRouter.route("/:id/actions/save-referral-data").post( + checkAbility("update", "Referral"), + asyncHandler(async (req, res) => { + const existingReferral = await ReferralModel.findById(req.params.id).accessibleBy(req.ability); + + if (!existingReferral) { + throw new BadRequestError("No referral exists with this id or you do not have permission."); + } + + const incomingReferralData = req.body.referralData ?? {}; + let { resume } = existingReferral.referralData; + if (Object.prototype.hasOwnProperty.call(incomingReferralData, "resume")) { + resume = incomingReferralData.resume?.id || undefined; + } + + const allowedReferralData = _.pick(req.body.referralData ?? {}, [ + "firstName", + "lastName", + "email", + "phoneNumber", + "school", + "schoolYear", + "referForReimbursement", + "referForEarlyApplication", + "essay", + ]); + + const nextReferralData = { + ...existingReferral.referralData, + ...allowedReferralData, + resume, + }; + + let hydratedResume; + if (existingReferral.status === ReferralStatusType.SUBMITTED) { + if (resume) { + hydratedResume = await apiCall( + Service.FILES, + { + method: "GET", + url: `files/${resume}`, + params: { + hexathon: existingReferral.hexathon.toString(), + }, + }, + req + ); + } + + await validateReferralData({ + ...nextReferralData, + resume: hydratedResume, + }); + } + + const updatedReferral = await ReferralModel.findByIdAndUpdate( + req.params.id, + { + referralData: nextReferralData, + }, + { new: true, runValidators: true } + ); + + if (!updatedReferral) { + throw new BadRequestError("Error saving referral data."); + } + + const referralData = { ...updatedReferral.referralData } as any; + if (referralData.resume) { + referralData.resume = + hydratedResume ?? + (await apiCall( + Service.FILES, + { + method: "GET", + url: `files/${referralData.resume}`, + params: { + hexathon: updatedReferral.hexathon.toString(), + }, + }, + req + )); + } + + return res.send({ + ...updatedReferral.toJSON(), + referralData, + }); + }) +); + +referralRouter.route("/:id/actions/submit-referral").post( + checkAbility("update", "Referral"), + asyncHandler(async (req, res) => { + const existingReferral = await ReferralModel.findById(req.params.id).accessibleBy(req.ability); + + if (!existingReferral) { + throw new BadRequestError("No referral exists with this id or you do not have permission."); + } + + let resume; + if (existingReferral.referralData.resume) { + resume = await apiCall( + Service.FILES, + { + method: "GET", + url: `files/${existingReferral.referralData.resume}`, + params: { + hexathon: existingReferral.hexathon.toString(), + }, + }, + req + ); + } + const referralData = { ...existingReferral.referralData, resume }; + await validateReferralData(referralData); + + await ReferralModel.findByIdAndUpdate( + req.params.id, + { + status: ReferralStatusType.SUBMITTED, + referralSubmitTime: new Date(), + }, + { new: true, runValidators: true } + ); + + return res.sendStatus(204); + }) +);