diff --git a/constants/routes.constant.js b/constants/routes.constant.js index d5bd5a91..af6198b2 100644 --- a/constants/routes.constant.js +++ b/constants/routes.constant.js @@ -222,7 +222,15 @@ const searchRoutes = { "get": { requestType: Constants.REQUEST_TYPES.GET, uri: "/api/search/" - } + }, + "updateStatus": { + requestType: Constants.REQUEST_TYPES.GET, + uri: "/api/search/updateStatus", + }, + "sendEmails": { + requestType: Constants.REQUEST_TYPES.GET, + uri: "/api/search/sendEmails", + }, }; const staffRoutes = { diff --git a/constants/success.constant.js b/constants/success.constant.js index 6820d13f..64903db8 100644 --- a/constants/success.constant.js +++ b/constants/success.constant.js @@ -21,6 +21,7 @@ const HACKER_GET_BY_ID = "Hacker found by id."; const HACKER_READ = "Hacker retrieval successful."; const HACKER_CREATE = "Hacker creation successful."; const HACKER_UPDATE = "Hacker update successful."; +const HACKER_UPDATE_EMAILS = "Hacker update emails sent." const HACKER_SENT_WEEK_OF = "Hacker week-of email sent." const HACKER_SENT_DAY_OF = "Hacker day-of email sent." @@ -72,6 +73,7 @@ module.exports = { HACKER_READ: HACKER_READ, HACKER_CREATE: HACKER_CREATE, HACKER_UPDATE: HACKER_UPDATE, + HACKER_UPDATE_EMAILS: HACKER_UPDATE_EMAILS, HACKER_SENT_WEEK_OF: HACKER_SENT_WEEK_OF, HACKER_SENT_DAY_OF: HACKER_SENT_DAY_OF, diff --git a/controllers/search.controller.js b/controllers/search.controller.js index 52ca25de..b144e477 100644 --- a/controllers/search.controller.js +++ b/controllers/search.controller.js @@ -5,6 +5,7 @@ const Services = { }; const Util = require("../middlewares/util.middleware"); const Success = require("../constants/success.constant"); +const ErrorMessages = require("../constants/error.constant") async function searchResults(req, res) { let results = req.body.results; @@ -21,6 +22,22 @@ async function searchResults(req, res) { }); } +async function emailResults(req, res) { + let results = req.body.results; + let message; + if (results === undefined) { + message = Success.HACKER_UPDATE_EMAILS; + results = {} + } else { + message = ErrorMessages.EMAIL_500_MESSAGE; + } + return res.status(200).json({ + message: message, + data: results + }); +} + module.exports = { - searchResults: Util.asyncMiddleware(searchResults) + searchResults: Util.asyncMiddleware(searchResults), + emailResults: Util.asyncMiddleware(emailResults) }; \ No newline at end of file diff --git a/middlewares/search.middleware.js b/middlewares/search.middleware.js index b6707568..e19b2b97 100644 --- a/middlewares/search.middleware.js +++ b/middlewares/search.middleware.js @@ -15,7 +15,6 @@ const Middleware = { */ function parseQuery(req, res, next) { let query = req.body.q; - req.body.q = JSON.parse(query); //Default page @@ -62,6 +61,48 @@ async function executeQuery(req, res, next) { return next(); } +/** + * + * @param {} req + * @param {*} res + * @param {*} next + * + * @returns + */ +async function executeStatusAction(req, res, next) { + req.body.results = await Services.Search.executeStatusAction(req.body.model, + req.body.q, + req.body.page, + req.body.limit, + req.body.sort, + req.body.sort_by, + req.body.update, + req.body.expand + ); + return next(); +} + +/** + * + * @param {} req + * @param {*} res + * @param {*} next + * + * @returns + */ +async function executeEmailAction(req, res, next) { + req.body.results = await Services.Search.executeEmailAction(req.body.model, + req.body.q, + req.body.page, + req.body.limit, + req.body.sort, + req.body.sort_by, + req.body.status, + req.body.expand + ); + return next(); +} + function setExpandTrue(req, res, next) { req.body.expand = true; next(); @@ -71,5 +112,7 @@ function setExpandTrue(req, res, next) { module.exports = { parseQuery: parseQuery, executeQuery: Middleware.Util.asyncMiddleware(executeQuery), + executeStatusAction: Middleware.Util.asyncMiddleware(executeStatusAction), + executeEmailAction: Middleware.Util.asyncMiddleware(executeEmailAction), setExpandTrue: setExpandTrue, }; \ No newline at end of file diff --git a/middlewares/validators/search.validator.js b/middlewares/validators/search.validator.js index 837c42ed..75f3ed93 100644 --- a/middlewares/validators/search.validator.js +++ b/middlewares/validators/search.validator.js @@ -11,4 +11,24 @@ module.exports = { VALIDATOR.booleanValidator("query", "expand", true), VALIDATOR.searchValidator("query", "q") ], + statusValidator: [ + VALIDATOR.searchModelValidator("query", "model", false), + VALIDATOR.alphaValidator("query", "sort", true), + VALIDATOR.integerValidator("query", "page", true, 0), + VALIDATOR.integerValidator("query", "limit", true, 0, 1000), + VALIDATOR.searchSortValidator("query", "sort_by"), + VALIDATOR.booleanValidator("query", "expand", true), + VALIDATOR.searchValidator("query", "q"), + VALIDATOR.updateHackerValidator("query", "update") + ], + emailValidator: [ + VALIDATOR.searchModelValidator("query", "model", false), + VALIDATOR.alphaValidator("query", "sort", true), + VALIDATOR.integerValidator("query", "page", true, 0), + VALIDATOR.integerValidator("query", "limit", true, 0, 1000), + VALIDATOR.searchSortValidator("query", "sort_by"), + VALIDATOR.booleanValidator("query", "expand", true), + VALIDATOR.searchValidator("query", "q"), + VALIDATOR.statusValidator("query", "status") + ] }; \ No newline at end of file diff --git a/middlewares/validators/validator.helper.js b/middlewares/validators/validator.helper.js index 3fff34b4..7135fd7b 100644 --- a/middlewares/validators/validator.helper.js +++ b/middlewares/validators/validator.helper.js @@ -27,8 +27,8 @@ function integerValidator(fieldLocation, fieldname, optional = true, lowerBound if (optional) { return value.optional({ - checkFalsy: true - }) + checkFalsy: true + }) .isInt().withMessage(`${fieldname} must be an integer.`) .custom((value) => { return value >= lowerBound && value <= upperBound; @@ -71,8 +71,8 @@ function mongoIdArrayValidator(fieldLocation, fieldname, optional = true) { if (optional) { return arr.optional({ - checkFalsy: true - }) + checkFalsy: true + }) .custom(isMongoIdArray).withMessage("Value must be an array of mongoIDs"); } else { return arr.exists() @@ -154,8 +154,8 @@ function regexValidator(fieldLocation, fieldname, optional = true, desire = Cons if (optional) { return match.optional({ - checkFalsy: true - }) + checkFalsy: true + }) .matches(desire) .withMessage("must be valid url"); } else { @@ -332,8 +332,8 @@ function jwtValidator(fieldLocation, fieldname, jwtSecret, optional = true) { const jwtValidationChain = setProperValidationChainBuilder(fieldLocation, fieldname, "Must be vali jwt"); if (optional) { return jwtValidationChain.optional({ - checkFalsy: true - }) + checkFalsy: true + }) .custom(value => { const token = jwt.verify(value, jwtSecret); if (typeof token !== "undefined") { @@ -423,8 +423,8 @@ function searchValidator(fieldLocation, fieldname) { function searchSortValidator(fieldLocation, fieldName) { const searchSort = setProperValidationChainBuilder(fieldLocation, fieldName, "Invalid sort criteria") return searchSort.optional({ - checkFalsy: true - }) + checkFalsy: true + }) .custom((value, { req }) => { @@ -568,6 +568,46 @@ function enumValidator(fieldLocation, fieldname, enums, optional = true) { } } +/** + * Validates that the field is a valid hacker update object, and checks if corresponding new status is valid. + * @param {"query" | "body" | "header" | "param"} fieldLocation The location where the field should be found. + * @param {string} actionFieldName The name of the action that needs to be performed. + * @param {string} statusFieldName The name of the action that needs to be performed. + */ +function updateHackerValidator(fieldLocation, fieldName) { + const hackerObjectValue = setProperValidationChainBuilder(fieldLocation, fieldName, "Invalid hacker update object string."); + + return hackerObjectValue.exists() + .withMessage("The hacker update object string must exist.") + .custom(updateHackerValidatorHelper).withMessage("The value must be a valid hacker update object."); +} + +function updateHackerValidatorHelper(update) { + try { + var updateObject = JSON.parse(update); + + if (updateObject && typeof updateObject === "object" && !("password" in updateObject)) { + for (var key in updateObject) { + var schemaPath = Models.Hacker.searchableField(key); + if (!schemaPath) return false; + } + return true; + } + } + catch (e) { + return false; + } +} + +function statusValidator(fieldLocation, statusFieldName) { + const statusValue = setProperValidationChainBuilder(fieldLocation, statusFieldName, "Invalid status."); + return statusValue.exists().withMessage("The status must exist!").custom((val, { + req + }) => { + return Constants.HACKER_STATUSES.includes(val); + }).withMessage("The value must be a proper status.") +} + /** * Checks that 'value' is part of 'enums'. 'enums' should be an enum dict. * @param {*} value Should be of the same type as the values of the enum @@ -631,5 +671,7 @@ module.exports = { dateValidator: dateValidator, enumValidator: enumValidator, routesValidator: routesValidator, - stringValidator: stringValidator + statusValidator: statusValidator, + stringValidator: stringValidator, + updateHackerValidator: updateHackerValidator, }; \ No newline at end of file diff --git a/routes/api/search.js b/routes/api/search.js index 9d1a4253..3f565563 100644 --- a/routes/api/search.js +++ b/routes/api/search.js @@ -67,6 +67,97 @@ module.exports = { Controllers.Search.searchResults ); + /** + * @api {get} /search/updateStatus execute an action on a specific query for any defined model + * @apiName search + * @apiGroup Search + * @apiVersion 0.0.8 + * + * @apiParam (query) {String} model the model to be searched + * @apiParam (query) {Array} q the query to be executed. For more information on how to format this, please see https://docs.mchacks.ca/architecture/ + * @apiParam (query) {String} model the model to be searched + * @apiParam (query) {String} sort either "asc" or "desc" + * @apiParam (query) {number} page the page number that you would like + * @apiParam (query) {number} limit the maximum number of results that you would like returned + * @apiParam (query) {any} sort_by any parameter you want to sort the results by + * @apiParam (query) {String} json object string containing the update fields for the defined model + * @apiParam (query) {boolean} expand whether you want to expand sub documents within the results + * + * @apiSuccess {String} message Success message + * @apiSuccess {Object} data Results + * @apiSuccessExample {object} Success-Response: + * { + "message": "Successfully executed query, returning all results", + "data": [ + {...} + ] + } + * + * @apiSuccess {String} message Success message + * @apiSuccess {Object} data Empty object + * @apiSuccessExample {object} Success-Response: + * { + "message": "No results found.", + "data": {} + } + * + * @apiError {String} message Error message + * @apiError {Object} data empty + * @apiErrorExample {object} Error-Response: + * {"message": "Validation failed", "data": {}} + */ + searchRouter.route("/updateStatus").get( + Middleware.Auth.ensureAuthenticated(), + Middleware.Auth.ensureAuthorized(), + Middleware.Validator.Search.statusValidator, + Middleware.parseBody.middleware, + Middleware.Search.parseQuery, + Middleware.Search.executeStatusAction, + Controllers.Search.searchResults + ); + + /** + * @api {get} /search/sendEmails execute an action on a specific query for any defined model + * @apiName search + * @apiGroup Search + * @apiVersion 0.0.8 + * + * @apiParam (query) {String} model the model to be searched + * @apiParam (query) {Array} q the query to be executed. For more information on how to format this, please see https://docs.mchacks.ca/architecture/ + * @apiParam (query) {String} model the model to be searched + * @apiParam (query) {String} sort either "asc" or "desc" + * @apiParam (query) {number} page the page number that you would like + * @apiParam (query) {number} limit the maximum number of results that you would like returned + * @apiParam (query) {any} sort_by any parameter you want to sort the results by + * @apiParam (query) {String} | + * @apiParam (query) {boolean} expand whether you want to expand sub documents within the results + * + * @apiSuccess {String} message Success message + * @apiSuccess {Object} data Results + * @apiSuccessExample {object} Success-Response: + * { + "message": "Hacker update emails sent.", + "data": {} + } + * + * @apiSuccess {String} message Error message + * @apiSuccess {Object} data Specific Error message + * @apiSuccessExample {object} Success-Response: + * { + "message": "Error while generating email", + "data": "Email service failed." + } + */ + searchRouter.route("/sendEmails").get( + Middleware.Auth.ensureAuthenticated(), + Middleware.Auth.ensureAuthorized(), + Middleware.Validator.Search.emailValidator, + Middleware.parseBody.middleware, + Middleware.Search.parseQuery, + Middleware.Search.executeEmailAction, + Controllers.Search.emailResults + ); + apiRouter.use("/search", searchRouter); } }; \ No newline at end of file diff --git a/services/email.service.js b/services/email.service.js index bd6492df..be20287c 100644 --- a/services/email.service.js +++ b/services/email.service.js @@ -18,7 +18,7 @@ class EmailService { * @param {*} mailData * @param {(err?)=>void} callback */ - send(mailData, callback = () => {}) { + send(mailData, callback = () => { }) { if (env.isTest()) { //Silence all actual emails if we're testing mailData.mailSettings = { @@ -41,7 +41,7 @@ class EmailService { * @param {*} mailData * @param {(err?)=>void} callback */ - sendMultiple(mailData, callback = () => {}) { + sendMultiple(mailData, callback = () => { }) { return client.sendMultiple(mailData, (error) => { if (error) { logger.error(`${TAG} ` + JSON.stringify(error)); @@ -125,6 +125,27 @@ class EmailService { } }, callback); } + + async sendStatusUpdateAsync(firstName, recipient, status) { + const handlebarsPath = path.join(__dirname, `../assets/email/statusEmail/${status}.hbs`); + const mailData = { + to: recipient, + from: process.env.NO_REPLY_EMAIL, + subject: Constants.EMAIL_SUBJECTS[status], + html: this.renderEmail(handlebarsPath, { + firstName: firstName + }) + }; + return this.send(mailData).then( + (response) => { + if (response[0].statusCode >= 200 && response[0].statusCode < 300) { + return undefined; + } else { + return response[0]; + } + }); + } + /** * Generates the HTML from the handlebars template file found at the given path. * @param {string} path the absolute path to the handlebars template file diff --git a/services/search.service.js b/services/search.service.js index 597a4cb1..36f33cbd 100644 --- a/services/search.service.js +++ b/services/search.service.js @@ -1,19 +1,20 @@ "use strict"; const Hacker = require("../models/hacker.model"); +const EmailService = require("./email.service") +const AccountService = require("./account.service") const logger = require("./logger.service"); /** - * @function executeQuery + * @function createQuery * @param {string} model the model which is being searched * @param {Array} queryArray array of clauses for the query * @param {number} page the page number you want * @param {number} limit the limit to the number of responses you want * @param {"asc"|"desc"} sort which direction you want to sort by - * @param {string} sort_by the attribute you want to sort by - * @returns {Promise<[Array]>} - * @description Builds and executes a search query based on a subset of mongodb + * @param {string} sortBy the attribute you want to sort by + * @returns {Query} Builds a query object and returns it based on the parameters provided */ -function executeQuery(model, queryArray, page, limit, sort, sort_by, shouldExpand = false) { +function createQuery(model, queryArray, page, limit, sort, sortBy, shouldExpand = false) { var query; switch (model.toLowerCase()) { case "hacker": @@ -63,15 +64,84 @@ function executeQuery(model, queryArray, page, limit, sort, sort_by, shouldExpan } if (sort == "desc") { - query.sort("-" + sort_by); + query.sort("-" + sortBy); } else if (sort == "asc") { - query.sort(sort_by); + query.sort(sortBy); + } + return query.limit(limit).skip(limit * page) +} + + +/** + * @function executeQuery + * @param {string} model the model which is being searched + * @param {Array} queryArray array of clauses for the query + * @param {number} page the page number you want + * @param {number} limit the limit to the number of responses you want + * @param {"asc"|"desc"} sort which direction you want to sort by + * @param {string} sortBy the attribute you want to sort by + * @returns {Promise<[Array]>} + * @description Builds and executes a search query based on a subset of mongodb + */ +function executeQuery(model, queryArray, page, limit, sort, sortBy, shouldExpand = false) { + var query = createQuery(model, queryArray, page, limit, sort, sortBy, shouldExpand); + return query.exec('find'); +} + + +/** + * @function executeStatusAction + * @param {string} model the model which is being searched + * @param {Array} queryArray array of clauses for the query + * @param {number} page the page number you want + * @param {number} limit the limit to the number of responses you want + * @param {"asc"|"desc"} sort which direction you want to sort by + * @param {string} sortBy the attribute you want to sort by + * @param {string} update the JSON string containing the keys and values to update to + * @returns {Promise<[Array]>} + * @description Builds and executes a status update based on a subset of mongodb + */ +function executeStatusAction(model, queryArray, page, limit, sort, sortBy, update, shouldExpand = false) { + var query = createQuery(model, queryArray, page, limit, sort, sortBy, shouldExpand); + var update_obj = JSON.parse(update); + return query.updateMany({ $set: update_obj }).exec(); +} + + +/** + * @function executeEmailAction + * @param {string} model the model which is being searched + * @param {Array} queryArray array of clauses for the query + * @param {number} page the page number you want + * @param {number} limit the limit to the number of responses you want + * @param {"asc"|"desc"} sort which direction you want to sort by + * @param {string} sortBy the attribute you want to sort by + * @param {"Accepted"|"Waitlisted"|"Reminder"} status the status type of the email to send to hackers + * @returns {Promise<[Array]>} + * @description Sends a status update email based on a subset of mongodb + */ +async function executeEmailAction(model, queryArray, page, limit, sort, sortBy, status, shouldExpand = false) { + var query = createQuery(model, queryArray, page, limit, sort, sortBy, shouldExpand); + const hackers = await query.exec() + + if (hackers) { + for (const hacker of hackers) { + const account = await AccountService.findById(hacker.accountId); + if (!account) { + break; + } + let emailError = await EmailService.sendStatusUpdateAsync(account.firstName, account.email, status) + if (emailError) { + return "Email service failed." + } + } + } else { + return "Email service failed." } - return query.limit(limit) - .skip(limit * page) - .exec(); } module.exports = { - executeQuery: executeQuery + executeQuery: executeQuery, + executeStatusAction: executeStatusAction, + executeEmailAction: executeEmailAction }; \ No newline at end of file