diff --git a/app.js b/app.js index 7efd85cc..1bc2b6bb 100755 --- a/app.js +++ b/app.js @@ -8,7 +8,7 @@ const Services = { log: require("./services/logger.service"), db: require("./services/database.service"), auth: require("./services/auth.service"), - env: require("./services/env.service") + env: require("./services/env.service"), }; const envLoadResult = Services.env.load(path.join(__dirname, "./.env")); @@ -31,6 +31,7 @@ const searchRouter = require("./routes/api/search"); const settingsRouter = require("./routes/api/settings"); const volunteerRouter = require("./routes/api/volunteer"); const roleRouter = require("./routes/api/role"); +const emailsRouter = require("./routes/api/emails"); const app = express(); Services.db.connect(); @@ -40,7 +41,7 @@ let corsOptions = {}; if (!Services.env.isProduction()) { corsOptions = { origin: [`http://${process.env.FRONTEND_ADDRESS_DEV}`], - credentials: true + credentials: true, }; } else { corsOptions = { @@ -48,34 +49,32 @@ if (!Services.env.isProduction()) { const allowedOrigins = [ `https://${process.env.FRONTEND_ADDRESS_DEPLOY}`, `https://${process.env.FRONTEND_ADDRESS_BETA}`, - `https://docs.mchacks.ca` + `https://docs.mchacks.ca`, ]; const regex = /^https:\/\/dashboard-[\w-]+\.vercel\.app$/; if ( allowedOrigins.includes(origin) || // Explicitly allowed origins - regex.test(origin) // Matches dashboard subdomains + regex.test(origin) // Matches dashboard subdomains ) { callback(null, true); } else { - callback(new Error('Not allowed by CORS')); + callback(new Error("Not allowed by CORS")); } }, - credentials: true + credentials: true, }; } - - app.use(cors(corsOptions)); app.use(Services.log.requestLogger); app.use(Services.log.errorLogger); app.use(express.json()); app.use( express.urlencoded({ - extended: false - }) + extended: false, + }), ); app.use(cookieParser()); //Cookie-based session tracking @@ -86,8 +85,8 @@ app.use( // Cookie Options maxAge: 48 * 60 * 60 * 1000, //Logged in for 48 hours sameSite: process.env.COOKIE_SAME_SITE, - secureProxy: !Services.env.isTest() - }) + secureProxy: !Services.env.isTest(), + }), ); app.use(passport.initialize()); app.use(passport.session()); //persistent login session @@ -116,10 +115,10 @@ settingsRouter.activate(apiRouter); Services.log.info("Settings router activated"); roleRouter.activate(apiRouter); Services.log.info("Role router activated"); +emailsRouter.activate(apiRouter); +Services.log.info("Emails router activated"); -apiRouter.use("/", indexRouter); app.use("/", indexRouter); - app.use("/api", apiRouter); //Custom error handler @@ -140,10 +139,10 @@ app.use((err, req, res, next) => { } res.status(status).json({ message: message, - data: errorContents + data: errorContents, }); }); module.exports = { - app: app + app: app, }; diff --git a/assets/email/statusEmail/Accepted.hbs b/assets/email/statusEmail/Accepted.hbs index f1ada263..2ff9ae55 100644 --- a/assets/email/statusEmail/Accepted.hbs +++ b/assets/email/statusEmail/Accepted.hbs @@ -392,7 +392,7 @@

Confirm your attendance on our hacker - dashboard no later than December 15th at 11:59PM EST. + dashboard no later than January 13th at 11:59PM EST.

If you can no longer attend McHacks, please let us know as soon as possible by withdrawing your application on our hacker dashboard until the deadline on November - 17th at + style="-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;font-weight: 700;">December + 20th at 11:59 PM ET.

In the meantime, follow us on void} next + */ +function validateStatus(req, res, next) { + const { status } = req.params; + const validStatuses = [ + Constants.General.HACKER_STATUS_ACCEPTED, + Constants.General.HACKER_STATUS_DECLINED, + ]; + + if (!validStatuses.includes(status)) { + return res.status(400).json({ + message: "Invalid status", + data: {}, + }); + } + + next(); +} + +/** + * Middleware to get count of hackers with specified status + * @param {{params: {status: string}}} req + * @param {*} res + * @param {(err?)=>void} next + */ +async function getStatusCount(req, res, next) { + const { status } = req.params; + + try { + const count = await Services.AutomatedEmail.getStatusCount(status); + req.body.count = count; + next(); + } catch (err) { + return res.status(500).json({ + message: err.message, + data: {}, + }); + } +} + +/** + * Middleware to send automated status emails + * @param {{params: {status: string}}} req + * @param {*} res + * @param {(err?)=>void} next + */ +async function sendAutomatedStatusEmails(req, res, next) { + const { status } = req.params; + + try { + const results = + await Services.AutomatedEmail.sendAutomatedStatusEmails(status); + req.body.results = results; + next(); + } catch (err) { + return res.status(500).json({ + message: err.message, + data: {}, + }); + } +} + +module.exports = { + validateStatus, + getStatusCount, + sendAutomatedStatusEmails, +}; diff --git a/routes/api/emails.js b/routes/api/emails.js new file mode 100644 index 00000000..f08eb582 --- /dev/null +++ b/routes/api/emails.js @@ -0,0 +1,75 @@ +"use strict"; +const express = require("express"); + +const Middleware = { + Auth: require("../../middlewares/auth.middleware"), + Email: require("../../middlewares/email.middleware"), +}; + +const Controllers = { + Email: require("../../controllers/email.controller"), +}; + +module.exports = { + activate: function (apiRouter) { + const automatedEmailRouter = express.Router(); + + /** + * @api {get} /email/automated/status/:status/count Get count of hackers with specified status + * @apiName getStatusEmailCount + * @apiGroup Email + * @apiVersion 0.0.8 + * + * @apiParam {string} status Status of hackers to count (Accepted/Declined) + * + * @apiSuccess {string} message Success message + * @apiSuccess {object} data Contains count of hackers + * @apiSuccessExample {object} Success-Response: + * { + * "message": "Successfully retrieved count", + * "data": { + * "count": 50 + * } + * } + */ + automatedEmailRouter.route("/automated/status/:status/count").get( + Middleware.Auth.ensureAuthenticated(), + Middleware.Auth.ensureAuthorized(), + Middleware.Email.validateStatus, + Middleware.Email.getStatusCount, + Controllers.Email.getStatusCount, + ); + + /** + * @api {post} /email/automated/status/:status Send emails to all hackers with specified status + * @apiName sendAutomatedStatusEmails + * @apiGroup Email + * @apiVersion 0.0.8 + * + * @apiParam {string} status Status of hackers to email (Accepted/Declined) + * + * @apiSuccess {string} message Success message + * @apiSuccess {object} data Contains counts of successful and failed emails + * @apiSuccessExample {object} Success-Response: + * { + * "message": "Successfully sent emails", + * "data": { + * "success": 50, + * "failed": 2 + * } + * } + */ + + automatedEmailRouter + .route("/automated/status/:status") + .post( + Middleware.Auth.ensureAuthenticated(), + Middleware.Auth.ensureAuthorized(), + Middleware.Email.validateStatus, + Middleware.Email.sendAutomatedStatusEmails, + Controllers.Email.sendAutomatedStatusEmails, + ); + + apiRouter.use("/email", automatedEmailRouter); + }, +}; diff --git a/services/auth.service.js b/services/auth.service.js index 5b4799d9..5977e2f7 100644 --- a/services/auth.service.js +++ b/services/auth.service.js @@ -8,9 +8,9 @@ module.exports = { emailAndPassStrategy: new LocalStrategy( { usernameField: "email", - passwordField: "password" + passwordField: "password", }, - function(email, password, done) { + function (email, password, done) { email = email.toLowerCase(); Account.getAccountIfValid(email, password).then( (account) => { @@ -22,29 +22,29 @@ module.exports = { }, (reason) => { done(reason, false); - } + }, ); - } + }, ), /** * Takes as input the id of the user. If the user id exists, it passes the user object to the callback * (done). The two arguments of the callback must be (failureReason, userObject). If there is no user, * then the userObject will be undefined. */ - deserializeUser: function(id, done) { + deserializeUser: function (id, done) { Account.findById(id).then( (user) => { done(null, user); }, (reason) => { done(reason); - } + }, ); }, - serializeUser: function(user, done) { + serializeUser: function (user, done) { done(null, user.id); }, - ensureAuthorized: ensureAuthorized + ensureAuthorized: ensureAuthorized, }; /** @@ -125,10 +125,13 @@ async function ensureAuthorized(req, findByIdFns) { currentlyValid = await verifySelfCase( findByIdFns[findByParamCount], splitPath[i], - req.user.id + req.user.id, ); findByParamCount += 1; break; + case ":status": + currentlyValid = true; + break; default: currentlyValid = false; break; @@ -156,12 +159,23 @@ async function ensureAuthorized(req, findByIdFns) { */ function verifyParamsFunctions(params, idFns) { let numParams = Object.values(params).length; + let validRoute = true; if (numParams === 0) { + // No params - should have no functions or undefined functions validRoute = !idFns || idFns.length === 0; } else { - validRoute = numParams === idFns.length; + // Has params - if no functions provided (undefined), assume general params like :status + if (!idFns) { + console.log( + `[Auth Service] Allowing general parameters (no validation functions needed)`, + ); + validRoute = true; + } else { + // Functions provided - must match param count + validRoute = numParams === idFns.length; + } } return validRoute; diff --git a/services/automatedEmails.service.js b/services/automatedEmails.service.js new file mode 100644 index 00000000..815a5534 --- /dev/null +++ b/services/automatedEmails.service.js @@ -0,0 +1,82 @@ +"use strict"; + +const Services = { + Email: require("./email.service"), + Hacker: require("./hacker.service"), + Logger: require("./logger.service"), +}; + +const TAG = "[AutomatedEmail.Service]"; + +class AutomatedEmailService { + /** + * Get count of hackers with the given status + * @param {string} status - "Accepted", "Declined" + * @returns {Promise} Count of hackers with the status + */ + async getStatusCount(status) { + try { + const hackers = await Services.Hacker.findByStatus(status); + if (!hackers || !Array.isArray(hackers)) { + return 0; + } + return hackers.length; + } catch (err) { + Services.Logger.error(`${TAG} Error in getStatusCount: ${err}`); + throw err; + } + } + + /** + * Send status emails to all hackers with the given status + * @param {string} status - "Accepted", "Declined" + * @returns {Promise<{success: number, failed: number}>} + */ + async sendAutomatedStatusEmails(status) { + const results = { success: 0, failed: 0 }; + try { + const hackers = await Services.Hacker.findByStatus(status); + + if (!hackers || !Array.isArray(hackers)) { + throw new Error( + `Expected array from findByStatus(${status}), got ${typeof hackers}`, + ); + } + + const emailPromises = hackers.map(async (hacker) => { + try { + await new Promise((resolve, reject) => { + Services.Email.sendStatusUpdate( + hacker.accountId.firstName, + hacker.accountId.email, + status, + (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }, + ); + }); + results.success++; + } catch (err) { + Services.Logger.error( + `${TAG} Failed to send ${status} email to ${hacker.accountId.email}: ${err}`, + ); + results.failed++; + } + }); + + await Promise.all(emailPromises); + return results; + } catch (err) { + Services.Logger.error( + `${TAG} Error in sendAutomatedStatusEmails: ${err}`, + ); + throw err; + } + } +} + +module.exports = new AutomatedEmailService(); diff --git a/services/email.service.js b/services/email.service.js index af9abde1..f8c15e1a 100644 --- a/services/email.service.js +++ b/services/email.service.js @@ -23,18 +23,19 @@ class EmailService { //Silence all actual emails if we're testing mailData.mailSettings = { sandboxMode: { - enable: true - } + enable: true, + }, }; } - return client.send(mailData, false) - .then(response => { - callback() - return response - }) - .catch(error => { - callback(error) + return client + .send(mailData, false) + .then((response) => { + callback(); + return response; }) + .catch((error) => { + callback(error); + }); } /** * Send separate emails to the list of users in mailData @@ -42,14 +43,15 @@ class EmailService { * @param {(err?)=>void} callback */ sendMultiple(mailData, callback = () => {}) { - return client.sendMultiple(mailData) - .then(response => { - callback() + return client + .sendMultiple(mailData) + .then((response) => { + callback(); return response; }) - .catch(error => { - callback(error) - }) + .catch((error) => { + callback(error); + }); } /** * Send email with ticket. @@ -61,17 +63,17 @@ class EmailService { sendWeekOfEmail(firstName, recipient, ticket, callback) { const handlebarsPath = path.join( __dirname, - `../assets/email/Ticket.hbs` + `../assets/email/Ticket.hbs`, ); const html = this.renderEmail(handlebarsPath, { firstName: firstName, - ticket: ticket + ticket: ticket, }); const mailData = { to: recipient, from: process.env.NO_REPLY_EMAIL, subject: Constants.EMAIL_SUBJECTS[Constants.WEEK_OF], - html: html + html: html, }; this.send(mailData).then((response) => { if ( @@ -93,16 +95,16 @@ class EmailService { sendDayOfEmail(firstName, recipient, callback) { const handlebarsPath = path.join( __dirname, - `../assets/email/Welcome.hbs` + `../assets/email/Welcome.hbs`, ); const html = this.renderEmail(handlebarsPath, { - firstName: firstName + firstName: firstName, }); const mailData = { to: recipient, from: process.env.NO_REPLY_EMAIL, subject: Constants.EMAIL_SUBJECTS[Constants.WEEK_OF], - html: html + html: html, }; this.send(mailData).then((response) => { if ( @@ -119,15 +121,15 @@ class EmailService { sendStatusUpdate(firstName, recipient, status, callback) { const handlebarsPath = path.join( __dirname, - `../assets/email/statusEmail/${status}.hbs` + `../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 - }) + firstName: firstName, + }), }; this.send(mailData).then((response) => { if ( diff --git a/services/hacker.service.js b/services/hacker.service.js index 37531700..d63e0bc4 100644 --- a/services/hacker.service.js +++ b/services/hacker.service.js @@ -37,10 +37,14 @@ function updateOne(id, hackerDetails) { const TAG = `[Hacker Service # update ]:`; const query = { - _id: id + _id: id, }; - return logger.logUpdate(TAG, "hacker", Hacker.findOneAndUpdate(query, hackerDetails, { new: true })); + return logger.logUpdate( + TAG, + "hacker", + Hacker.findOneAndUpdate(query, hackerDetails, { new: true }), + ); } /** @@ -67,7 +71,12 @@ async function findIds(queries) { let ids = []; for (const query of queries) { - let currId = await logger.logQuery(TAG, "hacker", query, Hacker.findOne(query, "_id")); + let currId = await logger.logQuery( + TAG, + "hacker", + query, + Hacker.findOne(query, "_id"), + ); ids.push(currId); } return ids; @@ -81,21 +90,45 @@ async function findIds(queries) { function findByAccountId(accountId) { const TAG = `[ Hacker Service # findByAccountId ]:`; const query = { - accountId: accountId + accountId: accountId, }; return logger.logUpdate(TAG, "hacker", Hacker.findOne(query)); } +/** + * Find all hackers with a specific status + * @param {string} status - The status to search for (e.g., "Accepted", "Declined") + * @return {Promise>} Array of hacker documents with the specified status + */ +async function findByStatus(status) { + const TAG = `[ Hacker Service # findByStatus ]:`; + const query = { status: status }; + + const result = await logger.logQuery( + TAG, + "hacker", + query, + Hacker.find(query).populate("accountId"), + ); + // Always return an array + if (!Array.isArray(result)) { + return []; + } + return result; +} + async function getStatsAllHackersCached() { const TAG = `[ hacker Service # getStatsAll ]`; if (cache.get(Constants.CACHE_KEY_STATS) !== null) { logger.info(`${TAG} Getting cached stats`); return cache.get(Constants.CACHE_KEY_STATS); } - const allHackers = await logger.logUpdate(TAG, "hacker", Hacker.find({})).populate({ - path: "accountId" - }); + const allHackers = await logger + .logUpdate(TAG, "hacker", Hacker.find({})) + .populate({ + path: "accountId", + }); cache.put(Constants.CACHE_KEY_STATS, stats, Constants.CACHE_TIMEOUT_STATS); //set a time-out of 5 minutes return getStats(allHackers); } @@ -106,7 +139,7 @@ async function getStatsAllHackersCached() { */ async function generateQRCode(str) { const response = await QRCode.toDataURL(str, { - scale: 4 + scale: 4, }); return response; } @@ -139,7 +172,7 @@ function getStats(hackers) { dietaryRestrictions: {}, shirtSize: {}, age: {}, - applicationDate: {} + applicationDate: {}, }; hackers.forEach((hacker) => { @@ -213,7 +246,9 @@ function getStats(hackers) { // const age = hacker.accountId.getAge(); // stats.age[age] = stats.age[age] ? stats.age[age] + 1 : 1; - stats.age[hacker.accountId.age] = stats.age[hacker.accountId.age] ? stats.age[age] + 1 : 1; + stats.age[hacker.accountId.age] = stats.age[hacker.accountId.age] + ? stats.age[age] + 1 + : 1; const applicationDate = hacker._id .getTimestamp() // @@ -235,8 +270,9 @@ module.exports = { updateOne: updateOne, findIds: findIds, findByAccountId: findByAccountId, + findByStatus: findByStatus, getStats: getStats, getStatsAllHackersCached: getStatsAllHackersCached, generateQRCode: generateQRCode, - generateHackerViewLink: generateHackerViewLink + generateHackerViewLink: generateHackerViewLink, };