diff --git a/api/cloud_api/email_templates/membershipConfirmationCode.js b/api/cloud_api/email_templates/membershipConfirmationCode.js
new file mode 100644
index 000000000..ff02e9d0f
--- /dev/null
+++ b/api/cloud_api/email_templates/membershipConfirmationCode.js
@@ -0,0 +1,20 @@
+function membershipConfirmationCode(user, recipient, confirmCode) {
+ return new Promise((resolve, reject) => {
+ return resolve({
+ from: user,
+ to: recipient,
+ subject: 'SCE Membership Confirmation',
+ generateTextFromHTML: true,
+ html: `
+
+ Hi,
+ Thank you for signing up for membership!
+ Please use the below confirmation code when you
+ visit your profile page on the
+ SCE website to verify your membership:
${confirmCode}
+
+ `
+ });
+ });
+}
+module.exports = { membershipConfirmationCode };
diff --git a/api/cloud_api/routes/Mailer.js b/api/cloud_api/routes/Mailer.js
index dca18e3be..605359f29 100644
--- a/api/cloud_api/routes/Mailer.js
+++ b/api/cloud_api/routes/Mailer.js
@@ -5,9 +5,11 @@ const { verification } = require('../email_templates/verification');
const { passwordReset } = require('../email_templates/passwordReset');
const { blastEmail } = require('../email_templates/blastEmail');
const { unsubscribeEmail } = require('../email_templates/unsubscribeEmail');
+const { membershipConfirmationCode } = require('../email_templates/membershipConfirmationCode');
const {
OK,
- BAD_REQUEST
+ BAD_REQUEST,
+ SERVER_ERROR
} = require('../../util/constants').STATUS_CODES;
const logger = require('../../util/logger');
const { googleApiKeys } = require('../../config/config.json');
@@ -134,4 +136,52 @@ router.post('/sendUnsubscribeEmail', async (req, res) => {
return res.sendStatus(OK);
});
+router.post('/sendMembershipConfirmationCode', async (req, res) => {
+ if (!ENABLED && process.env.NODE_ENV !== 'test') {
+ return res.sendStatus(OK);
+ }
+ const scopes = ['https://mail.google.com/'];
+ const pathToToken = __dirname + '/../../config/token.json';
+ const apiHandler = new SceGoogleApiHandler(scopes, pathToToken);
+ const tokenJson = await apiHandler.checkIfTokenFileExists();
+
+ if (tokenJson) {
+ if (apiHandler.checkIfTokenIsExpired(tokenJson)) {
+ logger.warn('refreshing token');
+ MetricsHandler.gcpRefreshTokenLastUpdated.set(Math.floor(Date.now() / 1000));
+ apiHandler.refreshToken();
+ }
+ } else {
+ logger.warn('getting new token! ', { tokenJson });
+ apiHandler.getNewToken();
+ }
+
+ const { recipientEmail, confirmationCode } = req.body;
+
+ if (!recipientEmail || !confirmationCode) {
+ logger.warn('Missing recipientEmail or confirmationCode', { body: req.body });
+ return res.status(BAD_REQUEST).json({
+ error: 'recipientEmail and confirmationCode are required',
+ });
+ }
+
+ await membershipConfirmationCode(USER, recipientEmail, confirmationCode)
+ .then((template) => {
+ apiHandler
+ .sendEmail(template)
+ .then((_) => {
+ res.sendStatus(OK);
+ MetricsHandler.emailSent.inc({ type: 'membershipConfirmationCode' });
+ })
+ .catch((err) => {
+ logger.error('unable to send confirmation code: ', err);
+ res.sendStatus(SERVER_ERROR);
+ });
+ })
+ .catch((err) => {
+ logger.error('unable to generate member confirmation email template: ', err);
+ res.sendStatus(SERVER_ERROR);
+ });
+});
+
module.exports = router;
diff --git a/api/main_endpoints/models/MembershipPayment.js b/api/main_endpoints/models/MembershipPayment.js
new file mode 100644
index 000000000..cdfe8a5bd
--- /dev/null
+++ b/api/main_endpoints/models/MembershipPayment.js
@@ -0,0 +1,41 @@
+const mongoose = require('mongoose');
+const Schema = mongoose.Schema;
+
+
+const MembershipPaymentSchema = new Schema(
+ {
+ createdAt: {
+ type: Date,
+ default: Date.now
+ },
+ userId: {
+ type: Schema.Types.ObjectId,
+ ref: 'User',
+ default: null,
+ },
+ status: {
+ type: String,
+ enum: ['pending', 'completed', 'rejected'],
+ default: 'pending',
+ required: true,
+ },
+ confirmationCode: {
+ type: String,
+ unique: true,
+ required: true,
+ },
+ amount: {
+ type: Number,
+ required: true,
+ },
+ venmoDetails:{
+ transactionId: { type: String },
+ payerName: { type: String },
+ note: { type: String },
+ }
+ },
+ { collection: 'MembershipPayments' }
+);
+
+
+module.exports = mongoose.model('MembershipPayment', MembershipPaymentSchema);
diff --git a/api/main_endpoints/routes/MembershipPayment.js b/api/main_endpoints/routes/MembershipPayment.js
new file mode 100644
index 000000000..093dd8817
--- /dev/null
+++ b/api/main_endpoints/routes/MembershipPayment.js
@@ -0,0 +1,71 @@
+const express = require('express');
+const router = express.Router();
+const bodyParser = require('body-parser');
+router.use(bodyParser.json());
+const {
+ BAD_REQUEST,
+ SERVER_ERROR,
+ NOT_FOUND,
+ OK,
+} = require('../../util/constants').STATUS_CODES;
+const membershipState = require('../../util/constants').MEMBERSHIP_STATE;
+const User = require('../models/User');
+const { getMemberExpirationDate, updateMembershipExpiration } = require('../util/userHelpers');
+const { findVerifyPayment, rejectPayment } = require('../util/membershipPaymentQueries');
+const { decodeToken } = require('../util/token-functions.js');
+
+router.post('/verifyMembership', async (req, res) => {
+ const decoded = await decodeToken(req, membershipState.PENDING);
+ if (decoded.status !== OK) {
+ return res.sendStatus(decoded.status);
+ }
+
+ const { confirmationCode } = req.body;
+ const userId = decoded.token._id;
+
+ if (!confirmationCode) {
+ return res.sendStatus(BAD_REQUEST);
+ }
+
+ const paymentDocument = await findVerifyPayment(confirmationCode, userId);
+ if (paymentDocument === null){
+ return res.sendStatus(SERVER_ERROR);
+ }
+ if (paymentDocument === false){
+ return res.sendStatus(NOT_FOUND);
+ }
+
+ const paymentId = paymentDocument._id;
+ const { amount } = paymentDocument;
+
+ if (amount < 20){
+ const rejected = await rejectPayment(paymentId);
+ if (rejected === null){
+ return res.sendStatus(SERVER_ERROR);
+ }
+ if (rejected === false){
+ return res.sendStatus(NOT_FOUND);
+ }
+ return res.sendStatus(BAD_REQUEST);
+ }
+
+ let semestersToAdd = 0;
+ if (amount >= 30) {
+ semestersToAdd = 2;
+ } else {
+ semestersToAdd = 1;
+ }
+
+ const membershipUpdateResult = await updateMembershipExpiration(
+ decoded.token._id,
+ semestersToAdd
+ );
+
+ if (membershipUpdateResult === null) {
+ return res.sendStatus(SERVER_ERROR);
+ }
+ if (membershipUpdateResult === false) {
+ return res.status(NOT_FOUND).send('User not found.');
+ }
+ return res.sendStatus(OK);
+});
diff --git a/api/main_endpoints/util/emailHelpers.js b/api/main_endpoints/util/emailHelpers.js
index f9251f951..9742ff80a 100644
--- a/api/main_endpoints/util/emailHelpers.js
+++ b/api/main_endpoints/util/emailHelpers.js
@@ -40,4 +40,16 @@ async function sendPasswordReset(resetToken, email) {
});
}
-module.exports = { sendUnsubscribeEmail, sendVerificationEmail, sendPasswordReset };
+async function membershipConfirmationCode(confirmCode, email) {
+ return new Promise((resolve) => {
+ axios
+ .post(`${MAILER_API_URL}/Mailer/sendMembershipConfirmationCode`, {
+ recipientEmail: email,
+ confirmationCode: confirmCode
+ })
+ .then(() => resolve(true))
+ .catch(() => resolve(false));
+ });
+}
+
+module.exports = { sendUnsubscribeEmail, sendVerificationEmail, sendPasswordReset, membershipConfirmationCode };
diff --git a/api/main_endpoints/util/membershipPaymentQueries.js b/api/main_endpoints/util/membershipPaymentQueries.js
new file mode 100644
index 000000000..27361c2ba
--- /dev/null
+++ b/api/main_endpoints/util/membershipPaymentQueries.js
@@ -0,0 +1,62 @@
+import MembershipPayment from '../main_endpoints/models/MembershipPayment.js';
+
+const status = {
+ PENDING: 'pending',
+ COMPLETED: 'completed',
+ REJECTED: 'rejected',
+};
+
+function findVerifyPayment(confirmationCode, userId) {
+ return new Promise((resolve) => {
+ try {
+ MembershipPayment.findOneAndUpdate(
+ {
+ confirmationCode,
+ status: status.PENDING,
+ },
+ {
+ $set: { userId, status: status.COMPLETED },
+ },
+ {
+ new: true,
+ runValidators: true,
+ },
+ (error, result) => {
+ if (error) {
+ return resolve(null);
+ }
+ if (!result) {
+ return resolve(false);
+ }
+ return resolve(result);
+ }
+ );
+ } catch (error) {
+ return resolve(null);
+ }
+ });
+}
+
+function rejectPayment(paymentId) {
+ return new Promise((resolve) => {
+ try {
+ MembershipPayment.findByIdAndUpdate(
+ paymentId,
+ { $set: { status: status.REJECTED } },
+ (error, result) => {
+ if (error) {
+ return resolve(null);
+ }
+ if (!result) {
+ return resolve(false);
+ }
+ return resolve(true);
+ }
+ );
+ } catch (error) {
+ return resolve(null);
+ }
+ });
+}
+
+module.exports = { findVerifyPayment, rejectPayment };
diff --git a/api/main_endpoints/util/userHelpers.js b/api/main_endpoints/util/userHelpers.js
index 8ac327872..022aa9abc 100644
--- a/api/main_endpoints/util/userHelpers.js
+++ b/api/main_endpoints/util/userHelpers.js
@@ -203,6 +203,30 @@ function checkIfPageCountResets(lastLogin) {
return lastLoginWasOverOneWeekAgo || aSundayHasPassedSinceLastLogin;
}
+/**
+ * Update a user's membershipValidUntil date
+ * @param {String} userId - The user's ID
+ * @param {Number} numberOfSemestersToSignUpFor - Number of semesters to extend
+ * @returns {Object} result - Contains success status and message
+ */
+async function updateMembershipExpiration(userId, numberOfSemestersToSignUpFor) {
+ try {
+ const newExpiration = getMemberExpirationDate(numberOfSemestersToSignUpFor);
+ const user = await User.findByIdAndUpdate(
+ userId,
+ { membershipValidUntil: newExpiration },
+ { new: true },
+ );
+ if (!user) {
+ return false;
+ }
+ return true;
+ } catch (error) {
+ logger.error('Error updating membership:', error);
+ return null;
+ }
+}
+
module.exports = {
registerUser,
getMemberExpirationDate,
@@ -211,4 +235,5 @@ module.exports = {
userWithEmailExists,
checkIfPageCountResets,
findPasswordReset,
+ updateMembershipExpiration
};