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..fa38e0a27
--- /dev/null
+++ b/api/main_endpoints/routes/MembershipPayment.js
@@ -0,0 +1,115 @@
+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,
+ UNAUTHORIZED
+} = require('../../util/constants').STATUS_CODES;
+const membershipState = require('../../util/constants').MEMBERSHIP_STATE;
+const { updateMembershipExpiration } = require('../util/userHelpers');
+const { findVerifyPayment, rejectPayment, storePayment } = require('../util/membershipPaymentQueries.js');
+const { decodeToken } = require('../util/token-functions.js');
+const { membershipPayment = {} } = require('../../config/config.json');
+const { API_KEY = 'TUFFANYCHAR' } = membershipPayment;
+const crypto = require('crypto');
+const { membershipConfirmationCode } = require('../util/emailHelpers');
+const logger = require('../../util/logger');
+
+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);
+});
+
+router.post('/storePayment', async (req, res) => {
+ const apiKey = req.headers['x-api-key'];
+ if (!apiKey) {
+ return res.status(BAD_REQUEST).send('API key missing from request.');
+ }
+ if (apiKey !== API_KEY) {
+ return res.status(UNAUTHORIZED).send('Invalid API key.');
+ }
+ const { memberEmail, amount, venmoPaymentDetails } = req.body;
+ const required = [
+ { value: memberEmail, title: 'Member email', },
+ { value: amount, title: 'Valid payment amount', },
+ { value: venmoPaymentDetails, title: 'Venmo payment details', },
+ ];
+ const missingValue = required.find(({ value }) => !value);
+ if (missingValue) {
+ return res.status(BAD_REQUEST).send(`${missingValue.title} missing from request`);
+ }
+
+ const confirmationCode = crypto.randomBytes(4).toString('hex').toUpperCase();
+ const newPayment = {
+ userId: null,
+ confirmationCode,
+ amount,
+ venmoPaymentDetails,
+ };
+ const storeResult = await storePayment(newPayment);
+ if (!storeResult) {
+ return res.sendStatus(SERVER_ERROR);
+ }
+ const sendEmail = await membershipConfirmationCode(confirmationCode, memberEmail);
+ if (!sendEmail) {
+ logger.error('Failed to send membership confirmation email to:', memberEmail);
+ }
+ return res.sendStatus(OK);
+});
+
+module.exports = router;
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..2eadb1b4f
--- /dev/null
+++ b/api/main_endpoints/util/membershipPaymentQueries.js
@@ -0,0 +1,87 @@
+const MembershipPayment = require('../../models/MembershipPayment');
+
+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);
+ }
+ });
+}
+
+function storePayment({ userId, confirmationCode, amount, venmoPaymentDetails }) {
+ return new Promise((resolve) => {
+ try {
+ const newPayment = new MembershipPayment({
+ createdAt: new Date(),
+ userId,
+ confirmationCode,
+ amount,
+ venmoPaymentDetails,
+ });
+
+ newPayment.save((error) => {
+ if (error) {
+ logger.error('storePayment got an error saving to mongodb: ', error);
+ return resolve(false);
+ }
+ return resolve(true);
+ });
+ } catch (error) {
+ logger.error('storePayment caught an error: ', error);
+ return resolve(false);
+ }
+ });
+}
+
+module.exports = { findVerifyPayment, rejectPayment, storePayment };
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
};
diff --git a/src/APIFunctions/MembershipPayment.js b/src/APIFunctions/MembershipPayment.js
new file mode 100644
index 000000000..2da88e46b
--- /dev/null
+++ b/src/APIFunctions/MembershipPayment.js
@@ -0,0 +1,27 @@
+import { ApiResponse } from './ApiResponses';
+import { BASE_API_URL } from '../Enums';
+
+export async function verifyMembershipFromDb(token, confirmationCode) {
+ let status = new ApiResponse();
+ try {
+ const url = new URL('/api/MembershipPayment/verifyMembership', BASE_API_URL);
+ const res = await fetch(url.href, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({ confirmationCode })
+ });
+ if (res.ok) {
+ const result = await res.json();
+ status.responseData = result;
+ } else {
+ status.error = true;
+ }
+ } catch (err) {
+ status.error = true;
+ status.responseData = err;
+ }
+ return status;
+}
diff --git a/src/Pages/Profile/MemberView/Profile.js b/src/Pages/Profile/MemberView/Profile.js
index fc5ff3482..fcb4b72bc 100644
--- a/src/Pages/Profile/MemberView/Profile.js
+++ b/src/Pages/Profile/MemberView/Profile.js
@@ -3,6 +3,7 @@ import { getUserById } from '../../../APIFunctions/User';
import ChangePasswordModal from './ChangePassword';
import DeleteAccountModal from './DeleteAccountModal';
import GetApiKeyModal from './GetApiKeyModal';
+import VerifyMembershipModal from './VerifyMembershipModal';
import { membershipState, membershipStateToString } from '../../../Enums';
import { useSCE } from '../../../Components/context/SceContext';
@@ -53,6 +54,14 @@ export default function Profile() {
{response.firstName} {response.lastName}
+
+ document.getElementById('verify-membership-modal').showModal()
+ }
+ >
+ Verify Membership
+
@@ -110,6 +119,18 @@ export default function Profile() {
+ {
+ setBannerMessage(message);
+ setBannerColor(color);
+ setTimeout(() => {
+ setBannerMessage('');
+ setBannerColor('');
+ }, delay);
+ }}
+ accessLevel={response.accessLevel}
+ onVerificationSuccess={getUserFromApi}
+ />
{
setBannerMessage(message);
diff --git a/src/Pages/Profile/MemberView/VerifyMembershipModal.js b/src/Pages/Profile/MemberView/VerifyMembershipModal.js
new file mode 100644
index 000000000..e8f0f0a2b
--- /dev/null
+++ b/src/Pages/Profile/MemberView/VerifyMembershipModal.js
@@ -0,0 +1,99 @@
+import React, { useEffect, useState } from 'react';
+import { useSCE } from '../../../Components/context/SceContext';
+import { verifyMembershipFromDb } from '../../../APIFunctions/MembershipPayment';
+
+export default function VerifyMembershipModal(props) {
+ const { bannerCallback = (message, color) => { }, confirmClassAddons, accessLevel, onVerificationSuccess = () => {} } = props;
+ const [confirmationCode, setConfirmationCode] = useState('');
+ const { user } = useSCE();
+
+ const INPUT_CLASS_NAME = 'indent-2 block w-full rounded-md border-0 py-1.5 bg-white text-black shadow-sm ring-1 ring-inset ring-gray-300 placeholder-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6';
+
+ async function verifyMembership() {
+ const apiResponse = await verifyMembershipFromDb(
+ user.token,
+ confirmationCode,
+ );
+ if (apiResponse.error) {
+ bannerCallback('Unable to verify membership. Please try again later.');
+ return;
+ }
+ bannerCallback('Congrats, you confirmed your membership!');
+ setConfirmationCode('');
+ document.getElementById('verify-membership-modal').close();
+ onVerificationSuccess();
+ }
+
+ return (<>
+
+
+
Verify Membership
+ {accessLevel > 0 ? (
+ <>
+
+ You are already verified!!!
+
+
+ >
+ ) : (
+ <>
+
+ Please enter the confirmation code you received via email.
+
+
+ Confirmation Code
+
+
+ {
+ setConfirmationCode(e.target.value);
+ }}
+ className={INPUT_CLASS_NAME}
+ />
+
+
+
+ >
+ )}
+
+
+ >
+ );
+}