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,
};