diff --git a/README.md b/README.md
index aa462a5..89c3b30 100755
--- a/README.md
+++ b/README.md
@@ -53,16 +53,22 @@ Example:
```
retirementCalculator/
├── src/
-│ ├── views/
-│ │ └── partials/
-│ ├── css/
-│ ├── images/
-│ ├── scripts/
-│ └── utils/
+| ├── auth/
+| ├── database/
+│ ├── public/
+│ │ ├── images/
+│ │ ├── scripts/
+│ │ └── svgs/
+| |
+│ ├── router/
+│ ├── utils/
+│ └── views/
+│ └── partials/
│
-├── app.js
├── .env.example
├── .gitignore
+├── app.js
+├── CONTRIBUTING.md
├── LICENSE
├── package-lock.json
├── package.json
diff --git a/app.js b/app.js
index 1e3c914..b1935f5 100644
--- a/app.js
+++ b/app.js
@@ -54,9 +54,21 @@ app.get('/', (req, res) => {
});
app.get('/signup', (req, res) => {
+ let error = req.session.errMessage;
+ delete req.session.errMessage;
+
const ignore = ["User not found", "Incorrect password"];
- if (ignore.includes(req.session.errMessage)) req.session.errMessage = "";
- res.render('signup', { errMessage: req.session.errMessage });
+ if (ignore.includes(error)) error = "";
+
+ if (req.query.name && req.query.email) {
+ res.render('signup', {
+ errMessage: error,
+ name: req.query.name,
+ email: req.query.email
+ });
+ } else {
+ res.render('signup', { errMessage: error });
+ }
return res.status(status.Ok);
});
@@ -65,7 +77,15 @@ app.get('/login', (req, res) => {
res.redirect("/home");
return res.status(status.Ok);
}
- res.render('login', { errMessage: req.session.errMessage });
+
+ if (req.query.email) {
+ res.render('login', {
+ errMessage: req.session.errMessage,
+ email: req.query.email
+ });
+ } else {
+ res.render('login', { errMessage: req.session.errMessage });
+ }
return res.status(status.Ok);
});
diff --git a/src/auth/authentication.js b/src/auth/authentication.js
index f70635b..f89fc41 100644
--- a/src/auth/authentication.js
+++ b/src/auth/authentication.js
@@ -27,14 +27,14 @@ module.exports = (users) => {
router.post("/login", async (req, res) => {
const credentialSchema = joi.object({
- email: joi.string().email().required(),
- password: joi.string().alphanum().max(20).required(),
+ email: joi.string().email({ minDomainSegments: 2, tlds: { allow: true } }).required(),
+ password: joi.string().max(20).required(),
});
const valid = credentialSchema.validate(req.body);
- if (valid.err) {
- req.session.errMessage = "Invalid input";
+ if (valid.error) {
+ req.session.errMessage = "Invalid input:" + valid.error.details.map(d => d.message.replace(/"/g, '')).join(', ');
res.status(status.BadRequest);
return res.redirect("/login");
}
@@ -43,13 +43,13 @@ module.exports = (users) => {
if (!user) {
req.session.errMessage = "User not found";
res.status(status.NotFound);
- return res.redirect("/login");
+ return res.redirect(`/login/?email=${req.body.email}`);
}
if (!bcrypt.compareSync(req.body.password, user.password)) {
req.session.errMessage = "Incorrect password";
res.status(status.Unauthorized);
- return res.redirect("/login");
+ return res.redirect(`/login/?email=${req.body.email}`);
}
req.session.authenticated = true;
@@ -72,16 +72,16 @@ module.exports = (users) => {
router.post("/signup", async (req, res) => {
const userSchema = joi.object({
- email: joi.string().email().required(),
+ email: joi.string().email({ minDomainSegments: 2, tlds: { allow: true } }).required(),
name: joi.string().pattern(new RegExp('^[a-zA-Z]+$')).max(20).required(),
- password: joi.string().alphanum().max(20).min(8).required(),
- repassword: joi.string().alphanum().max(20).min(8).required(),
+ password: joi.string().max(20).min(8).required(),
+ repassword: joi.string().max(20).min(8).required(),
});
const valid = userSchema.validate(req.body);
-
- if (valid.err) {
- req.session.errMessage = "Invalid input",
+
+ if (valid.error) {
+ req.session.errMessage = valid.error.details[0].message,
res.status(status.BadRequest);
return res.redirect("/signup");
}
@@ -95,8 +95,9 @@ module.exports = (users) => {
if (req.body.password != req.body.repassword) {
req.session.errMessage = "Passwords must match";
+
res.status(status.BadRequest);
- return res.redirect("/signup");
+ return res.redirect(`/signup/?name=${req.body.name}&email=${req.body.email}`);
}
let strength = passwordStrength(req.body.password);
@@ -104,7 +105,7 @@ module.exports = (users) => {
if (strength.id < 2) {
req.session.errMessage = `Password ${strength.value}`;
res.status(status.BadRequest);
- return res.redirect("/signup");
+ return res.redirect(`/signup/?name=${req.body.name}&email=${req.body.email}`);
}
let hashedPassword = await bcrypt.hashSync(req.body.password, salt);
diff --git a/src/auth/forgotPass.js b/src/auth/forgotPass.js
index 424c633..2dce041 100644
--- a/src/auth/forgotPass.js
+++ b/src/auth/forgotPass.js
@@ -1,8 +1,9 @@
+const { passwordStrength } = require("check-password-strength");
+const nodeMail = require('nodemailer');
const express = require('express');
+const bcrypt = require('bcrypt');
const crypto = require('crypto');
const joi = require('joi');
-const nodeMail = require('nodemailer');
-const bcrypt = require('bcrypt');
require('dotenv').config();
const PORT = process.env.PORT;
@@ -20,7 +21,7 @@ module.exports = (users) => {
router.post('/auth/resetPass', async (req, res) => {
const resetSchema = joi.object({
- email: joi.string().email().required(),
+ email: joi.string().email({ minDomainSegments: 2, tlds: { allow: true } }).required(),
});
req.session.error = '';
req.session.reset = '';
@@ -40,7 +41,7 @@ module.exports = (users) => {
const token = crypto.randomBytes(32).toString('hex');
console.log(`The reset token is ${token}`)
- const expiration = Date.now() + 360000;
+ const expiration = Date.now() + 3600000;
await users.updateOne({ email }, {
$set: { resetToken: token, resetTokenExpires: expiration }
@@ -52,8 +53,14 @@ module.exports = (users) => {
from: process.env.EMAIL_USER,
to: email,
subject: 'Password reset',
- text: `reset your password here ${resetUrl} this link will expire within 1 hour`,
-
+ text: `Hi ${user.name},\nA request was sent to reset your password. If this wasn't you, please ignore this email.\nIf you sent the request, reset your password here ${resetUrl} this link will expire within 1 hour, \n Do not share this link with anyone.
+ \n \n Thankyou, The RCalculator team.`,
+ html: `
+
Hi ${user.name}
+ A request was sent to reset your password. If this wasn't you, please ignore this email.
+ If you sent the request, reset your password here. This link will expire in 1 hour.
+ 
+ Thank you,
The RCalculator team
`,
};
try {
@@ -76,7 +83,7 @@ module.exports = (users) => {
const valid = passwordSchema.validate({ password, confirmPassword });
if (valid.error) {
console.log("houston we have a problem"); // nice
- req.session.error = 'Invalid input';
+ req.session.error = "Invalid input:" + valid.error.details.map(d => d.message.replace(/"/g, '')).join(', ');;
res.status(status.BadRequest);
return res.redirect(`/reset/${token}`);
}
@@ -97,6 +104,13 @@ module.exports = (users) => {
req.session.error = 'Reset link is invalid.';
return res.redirect(`/reset`);
}
+
+ let strength = passwordStrength(password);
+ if (strength.id < 2) {
+ req.session.errMessage = `Password ${strength.value}`;
+ return res.redirect(`/reset/${token}`);
+ }
+
const hashPassword = await bcrypt.hash(password, 12);
await users.updateOne(
diff --git a/src/public/scripts/assetManager.js b/src/public/scripts/assetManager.js
index 16bbc37..b0eee0b 100644
--- a/src/public/scripts/assetManager.js
+++ b/src/public/scripts/assetManager.js
@@ -85,6 +85,8 @@ function lockAsset(assetId, icon) {
document.getElementById(`${key}-${assetId}`).disabled = true;
});
+ if (icon === "Car" || icon === "Motorcycle") document.getElementById(`year-${assetId}`).disabled = true;
+
document.getElementById(`save-${assetId}`).disabled = true;
document.getElementById(`save-${assetId}`).classList.remove("cursor-pointer");
document.getElementById(`save-${assetId}`).classList.add("cursor-not-allowed");
@@ -105,6 +107,8 @@ function unlockAsset(assetId, icon) {
document.getElementById(`${key}-${assetId}`).disabled = false;
});
+ if (icon === "Car" || icon === "Motorcycle") document.getElementById(`year-${assetId}`).disabled = false;
+
document.getElementById(`save-${assetId}`).disabled = false;
document.getElementById(`save-${assetId}`).classList.remove("cursor-not-allowed");
document.getElementById(`save-${assetId}`).classList.add("cursor-pointer");
@@ -133,6 +137,8 @@ function autoOpenCreate() {
* @param {string} assetId
*/
function selectIcon(selectedIcon, assetId="") {
+ toggleYear(selectedIcon.value, assetId);
+
document.getElementById(`dropdown-icon-button${assetId != "" ? "-" : ""}${assetId}`).value = selectedIcon.value;
document.getElementById(`icon${assetId != "" ? "-" : ""}${assetId}`).value = selectedIcon.value
@@ -144,5 +150,29 @@ function selectIcon(selectedIcon, assetId="") {
`;
}
+/**
+ * toggleYear shows or hides year input
+ * @param {string} type of asset
+ */
+function toggleYear(type, assetId="") {
+ if (type === "Car" || type === "Motorcycle") {
+ if (assetId) {
+ document.getElementById(`year-${assetId}`).disabled = false;
+ document.getElementById(`year-modify-${assetId}`).style.display = 'block';
+ } else {
+ document.getElementById('year-input').disabled = false;
+ document.getElementById('year-create').style.display = 'block';
+ }
+ } else {
+ if (assetId) {
+ document.getElementById(`year-${assetId}`).disabled = true;
+ document.getElementById(`year-modify-${assetId}`).style.display = 'none';
+ } else {
+ document.getElementById('year-input').disabled = true;
+ document.getElementById('year-create').style.display = 'none';
+ }
+ }
+}
+
resetRadio();
autoOpenCreate();
diff --git a/src/public/scripts/calcExchange.js b/src/public/scripts/calcExchange.js
deleted file mode 100644
index 29956e8..0000000
--- a/src/public/scripts/calcExchange.js
+++ /dev/null
@@ -1,3 +0,0 @@
-let rate = document.getElementsByClassName("countryButton").value;
-
-document.getElementById("exchange").innerHTML
\ No newline at end of file
diff --git a/src/public/scripts/dollarFormat.js b/src/public/scripts/dollarFormat.js
index a22f2c5..e962411 100644
--- a/src/public/scripts/dollarFormat.js
+++ b/src/public/scripts/dollarFormat.js
@@ -1,12 +1,12 @@
+
/**
* Function takes in a number and formats it to currency
- * @param {integer}
- * @returns nuber formatted in currency
+ * @param {number} value
+ * @returns {string} number formatted in currency
*/
-
document.addEventListener("DOMContentLoaded", () => {
const number = document.querySelectorAll(".planGoal");
- number.forEach(value => {
+ number.forEach((value) => {
num = parseFloat(value.textContent);
if (num >= 999999) {
value.textContent = "$" + (num /= 1000000) + "M";
diff --git a/src/public/scripts/editPlanModals.js b/src/public/scripts/editPlanModals.js
new file mode 100644
index 0000000..5c25837
--- /dev/null
+++ b/src/public/scripts/editPlanModals.js
@@ -0,0 +1,67 @@
+// Basic client-side form submission handler to process JSON response
+document.addEventListener('DOMContentLoaded', () => {
+ const form = document.getElementById('editPlanForm');
+ const errorMessagesDiv = document.getElementById('formErrorMessages');
+
+ // Store initial form values
+ const initialValues = {
+ name: form.name.value,
+ retirementAge: form.retirementAge.value,
+ retirementExpenses: form.retirementExpenses.value,
+ retirementAssets: form.retirementAssets.value,
+ retirementLiabilities: form.retirementLiabilities.value
+ };
+
+ const resetButton = document.getElementById('resetButton');
+ resetButton.addEventListener('click', function() {
+ form.name.value = initialValues.name;
+ form.retirementAge.value = initialValues.retirementAge;
+ form.retirementExpenses.value = initialValues.retirementExpenses;
+ form.retirementAssets.value = initialValues.retirementAssets;
+ form.retirementLiabilities.value = initialValues.retirementLiabilities;
+ if (errorMessagesDiv) {
+ errorMessagesDiv.style.display = 'none'; // Hide error message on reset
+ errorMessagesDiv.textContent = '';
+ }
+ });
+
+ if (form) {
+ form.addEventListener('submit', async function(event) {
+ event.preventDefault(); // Prevent traditional form submission
+ errorMessagesDiv.textContent = '';
+ errorMessagesDiv.style.display = 'none';
+
+ const formData = new FormData(form);
+ const data = {};
+ formData.forEach((value, key) => {
+ data[key] = value;
+ });
+
+ try {
+ const response = await fetch(form.action, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+
+ const result = await response.json();
+
+ if (response.ok && result.success) {
+ const actionUrl = form.action;
+ const urlParts = actionUrl.split('/');
+ const planId = urlParts[urlParts.length - 2];
+ window.location.href = `/plans/${planId}`;
+ } else {
+ errorMessagesDiv.textContent = result.message || 'An error occurred while updating the plan.';
+ errorMessagesDiv.style.display = 'block';
+ }
+ } catch (error) {
+ console.error('Error submitting form:', error);
+ errorMessagesDiv.textContent = 'A network error occurred. Please try again.';
+ errorMessagesDiv.style.display = 'block';
+ }
+ });
+ }
+});
\ No newline at end of file
diff --git a/src/public/scripts/footer.js b/src/public/scripts/footer.js
index e63f58a..03e9328 100644
--- a/src/public/scripts/footer.js
+++ b/src/public/scripts/footer.js
@@ -13,6 +13,7 @@ if(page !== "login" && page !== "signup" && page !== "forgotPassword" && page !=
factButton.addEventListener("click", () => {
factMenu.classList.toggle("hidden");
});
+
factSubmitButton.addEventListener("click", () => {
fetch("/fact", {
method: "POST",
diff --git a/src/public/scripts/geolocation.js b/src/public/scripts/geolocation.js
index 94c9c66..15d4bbb 100644
--- a/src/public/scripts/geolocation.js
+++ b/src/public/scripts/geolocation.js
@@ -1,9 +1,17 @@
+/**
+ * getLocation gets the geolocation of user on page load.
+ */
function getLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(getLatestExchange, error);
}
}
+/**
+ * update the currency exchange rate dropdown
+ * or display error
+ * @param {object} data
+ */
function update(data) {
if (data.data.message != "error") {
document.getElementById("loading").style = "display: none";
@@ -48,6 +56,10 @@ function update(data) {
}
}
+/**
+ * switchButton changes the selected exchange rate icon
+ * @param {element} clickedButton
+ */
function switchButton(clickedButton) {
document.getElementById("dropdown-country-button").value = clickedButton.value;
updateExchange(document.getElementById("dropdown-country-button").value);
@@ -60,10 +72,18 @@ function switchButton(clickedButton) {
`;
}
+/**
+ * updateExchange updates the exchange rate
+ * @param {number} exRate
+ */
function updateExchange(exRate) {
document.getElementById("exchange").innerHTML = "$1.00 = $" +`${(1 * exRate).toFixed(2)}`;
}
+/**
+ * getLatestExchange gets rates from API from a given location.
+ * @param {object} position coordinates
+ */
async function getLatestExchange(position) {
let lat = position.coords.latitude;
let lon = position.coords.longitude;
@@ -73,6 +93,10 @@ async function getLatestExchange(position) {
update(data);
}
+/**
+ * error displays error message
+ * @param {string} err
+ */
function error(err) {
const data = {
data: {
diff --git a/src/public/scripts/planManager.js b/src/public/scripts/planManager.js
new file mode 100644
index 0000000..9f0b1ac
--- /dev/null
+++ b/src/public/scripts/planManager.js
@@ -0,0 +1,47 @@
+/**
+ * editPlan redirect
+ * @param {string} planId
+ */
+function editPlan(planId) {
+ if (planId) {
+ window.location.href = `/plans/${planId}/edit`;
+ } else {
+ console.error('editPlan called without a planId');
+ alert('Cannot edit plan: Plan ID is missing.');
+ }
+}
+
+/**
+ * deletePlan handler
+ * @param {string} planId
+ */
+async function deletePlan(planId) {
+ if (!planId) {
+ console.error('deletePlan called without a planId');
+ alert('Cannot delete plan: Plan ID is missing.');
+ return;
+ }
+
+ if (confirm('Are you sure you want to delete this plan? This action cannot be undone.')) {
+ try {
+ const response = await fetch(`/plans/${planId}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ const result = await response.json();
+
+ if (response.ok && result.success) {
+ alert('Plan deleted successfully.');
+ window.location.href = '/plans'; // Redirect to the plans list page
+ } else {
+ alert(`Failed to delete plan: ${result.message || 'Unknown error'}`);
+ }
+ } catch (error) {
+ console.error('Error deleting plan:', error);
+ alert('An error occurred while trying to delete the plan. Please check the console for details and ensure the server is running.');
+ }
+ }
+}
diff --git a/src/public/scripts/planModals.js b/src/public/scripts/planModals.js
new file mode 100644
index 0000000..4eca1f2
--- /dev/null
+++ b/src/public/scripts/planModals.js
@@ -0,0 +1,85 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const openModalBtn = document.getElementById('openNewPlanModalBtn');
+ const newPlanDialog = document.getElementById('newPlanDialog');
+ const newPlanForm = document.getElementById('newPlanForm');
+ const submitNewPlanBtn = document.getElementById('submitNewPlanBtn');
+ const newPlanError = document.getElementById('newPlanError');
+ const resetNewPlanFormBtn = document.getElementById('resetNewPlanFormBtn');
+
+ let url = new URLSearchParams(window.location.search);
+ let modalValue = url.get('openModal');
+
+ if (modalValue === 'true') {
+ newPlanDialog.showModal();
+ }
+
+ openModalBtn.addEventListener('click', () => {
+ newPlanDialog.showModal();
+ // Error reset is now handled by 'close' event, but good to clear on open too
+ newPlanError.style.display = 'none';
+ newPlanError.textContent = '';
+ });
+
+ // Handles form reset and error clearing when dialog is closed by any means (Esc, Cancel button, backdrop click, successful submit)
+ newPlanDialog.addEventListener('close', () => {
+ newPlanForm.reset();
+ newPlanError.style.display = 'none';
+ newPlanError.textContent = '';
+ });
+
+ // Close dialog if user clicks on the backdrop
+ newPlanDialog.addEventListener('click', (event) => {
+ if (event.target === newPlanDialog) {
+ newPlanDialog.close(); // This will trigger the 'close' event listener above
+ }
+ });
+
+ resetNewPlanFormBtn.addEventListener('click', () => {
+ newPlanForm.reset();
+ newPlanError.style.display = 'none';
+ newPlanError.textContent = '';
+ });
+
+ newPlanForm.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ submitNewPlanBtn.disabled = true;
+ submitNewPlanBtn.textContent = 'Saving...';
+ newPlanError.style.display = 'none';
+
+ const formData = new FormData(newPlanForm);
+ const data = Object.fromEntries(formData.entries());
+
+ try {
+ const response = await fetch('/newPlan', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success) {
+ newPlanDialog.close(); // This will trigger the 'close' event listener
+ window.location.reload(); // Reload to see the new plan
+ } else {
+ newPlanError.textContent = result.message || 'Failed to create plan. Please try again.';
+ newPlanError.style.display = 'block';
+ }
+ } else {
+ const errorData = await response.json();
+ newPlanError.textContent = errorData.message || `Error: ${response.status} - ${response.statusText}`;
+ newPlanError.style.display = 'block';
+ }
+ } catch (error) {
+ console.error('Error submitting new plan:', error);
+ newPlanError.textContent = 'An unexpected error occurred. Please try again.';
+ newPlanError.style.display = 'block';
+ }
+ finally {
+ submitNewPlanBtn.disabled = false;
+ submitNewPlanBtn.textContent = 'Save Plan';
+ }
+ });
+});
\ No newline at end of file
diff --git a/src/router/user.js b/src/router/user.js
index 7a25e91..d633501 100644
--- a/src/router/user.js
+++ b/src/router/user.js
@@ -1,5 +1,5 @@
const getRates = require("../util/exchangeRate");
-const { calculateProgress, updatePlanProgressInDB } = require("../util/calculations");
+const { calculateProgress, updatePlanProgressInDB, updateProgress, calculateTotalAssetValue } = require("../util/calculations");
const suggestions = require("../util/suggestions");
const status = require("../util/statuses");
const ObjectId = require('mongodb').ObjectId;
@@ -24,29 +24,33 @@ const getAssetSchema = (type) => {
assetSchema = joi.object({
type: joi.string().valid("other", "stock", "saving").required(),
icon: joi.string().alphanum().required(),
- name: joi.string().alphanum().min(3).max(30).required(),
+ name: joi.string().min(3).max(30).required(),
value: joi.number().min(0).required(),
+ year: joi.number().min(1900).max(new Date().getFullYear()),
purchaseDate: joi.date().required(),
- description: joi.string().alphanum().max(240),
+ description: joi.string().max(240).min(0),
id: joi.string().alphanum(), // May be passed when updating existing asset
+ userId: joi.string().alphanum(),
});
break;
case "saving":
assetSchema = joi.object({
type: joi.string().valid("other", "stock", "saving").required(),
- name: joi.string().alphanum().min(3).max(30).required(),
+ name: joi.string().min(3).max(30).required(),
value: joi.number().min(0).required(),
id: joi.string().alphanum(), // May be passed when updating existing asset
+ userId: joi.string().alphanum(),
});
break;
case "stock":
assetSchema = joi.object({
type: joi.string().valid("other", "stock", "saving").required(),
- ticker: joi.string().alphanum().min(3).max(5).required(),
+ ticker: joi.string().min(3).max(5).required(),
price: joi.number().min(0).required(),
quantity: joi.number().min(1).required(),
purchaseDate: joi.date().required(),
id: joi.string().alphanum(), // May be passed when updating existing asset
+ userId: joi.string().alphanum(),
});
break;
default:
@@ -105,29 +109,37 @@ module.exports = (middleware, users, plans, assets) => {
});
router.get('/assets', async (req, res) => {
+
+ if (!req.session.user.financialData || !req.session.user) {
+ req.session.errMessage = "Please complete your financial data before creating a plan.";
+ return res.status(status.Unauthorized).redirect('/questionnaire');
+ }
+
let userAssets = await assets.find({ userId: new ObjectId(req.session.userId) }).toArray();
+ let totalUserAssetValue = await calculateTotalAssetValue(userAssets);
+
res.render('assets', {
user: req.session.user,
errMessage: req.session.errMessage,
assets: userAssets,
geoData: req.session.geoData,
+ totalUserAssetValue: totalUserAssetValue,
icons: icons,
});
return res.status(status.Ok);
});
router.get('/plans', async (req, res) => {
+
+ if (!req.session.user.financialData || !req.session.user) {
+ req.session.errMessage = "Please complete your financial data before creating a plan.";
+ return res.status(status.Unauthorized).redirect('/questionnaire');
+ }
+
try {
- const userPlansFromDB = await plans.find({ userId: new ObjectId(req.session.userId) }).toArray();
-
- for (const plan of userPlansFromDB) {
- const progress = await calculateProgress(plan, assets, users, req.session.user._id);
- await updatePlanProgressInDB(plan._id, progress.percentage, plans);
- }
-
- const updatedUserPlans = await plans.find({ userId: new ObjectId(req.session.user._id) }).toArray();
-
+ const updatedUserPlans = await updateProgress(plans, assets, users, req.session.user._id);
+
res.render('plans', {
user: req.session.user,
plans: updatedUserPlans,
@@ -145,28 +157,28 @@ module.exports = (middleware, users, plans, assets) => {
try {
const planId = req.params.id;
let userAssets = await assets.find({ userId: new ObjectId(req.session.userId) }).toArray();
+ let totalUserAssetValue = await calculateTotalAssetValue(userAssets);
if (!ObjectId.isValid(planId)) {
req.session.errMessage = "Invalid plan ID format.";
return res.status(status.BadRequest).redirect('/plans');
}
-
const plan = await plans.findOne({ userId: new ObjectId(req.session.userId), _id: new ObjectId(planId) });
if (!plan) {
- console.log(`Plan not found with ID: ${planId} for user: ${req.session.userId}`);
req.session.errMessage = "Plan not found or you do not have permission to view it.";
return res.status(status.NotFound).redirect('/plans');
}
- const progress = await calculateProgress(plan, assets, users, req.session.userId);
+ const progress = await calculateProgress(plan, assets, users, req.session.userId);
res.render('planDetail', {
user: req.session.user,
- plan: plan,
+ plan: plan,
geoData: req.session.geoData,
+ totalUserAssetValue: totalUserAssetValue,
assets: userAssets,
- progress: progress,
+ progress: progress,
suggestions: await suggestions.generateSuggestions(),
});
@@ -177,21 +189,27 @@ module.exports = (middleware, users, plans, assets) => {
}
});
- router.get('/newPlan', (req, res) => {
+ // router.get('/newPlan', (req, res) => {
+ // if (!req.session.user.financialData || !req.session.user) {
+ // req.session.errMessage = "Please complete your financial data before creating a plan.";
+ // return res.status(status.Unauthorized).redirect('/questionnaire');
+ // }
+ // const errMessage = req.session.errMessage;
+ // req.session.errMessage = "";
+ // res.render('newPlan', {
+ // user: req.session.user,
+ // errMessage: errMessage,
+ // geoData: req.session.geoData
+ // });
+ // });
+
+ router.post('/newPlan', async (req, res) => {
+
if (!req.session.user.financialData || !req.session.user) {
req.session.errMessage = "Please complete your financial data before creating a plan.";
return res.status(status.Unauthorized).redirect('/questionnaire');
}
- const errMessage = req.session.errMessage;
- req.session.errMessage = "";
- res.render('newPlan', {
- user: req.session.user,
- errMessage: errMessage,
- geoData: req.session.geoData
- });
- });
- router.post('/newPlan', async (req, res) => {
const planSchema = joi.object({
name: joi.string().min(3).max(100).required(),
retirementAge: joi.number().min(18).max(120).required(),
@@ -205,29 +223,134 @@ module.exports = (middleware, users, plans, assets) => {
if (error) {
console.error("Plan validation error:", error.details);
- req.session.errMessage = "Invalid input: " + error.details.map(d => d.message.replace(/"/g, '')).join(', ');
- res.status(status.BadRequest).redirect("/newPlan");
+ const errorMessage = "Invalid input: " + error.details.map(d => d.message.replace(/"/g, '')).join(', ');
+ res.status(status.BadRequest).json({ success: false, message: errorMessage });
return;
}
- const newPlan = {
+
+ const planToInsert = {
userId: new ObjectId(req.session.userId),
name: value.name,
retirementAge: value.retirementAge,
retirementExpenses: value.retirementExpenses,
retirementAssets: value.retirementAssets,
retirementLiabilities: value.retirementLiabilities,
- progress: "0"
+ progress: "0"
};
try {
- await plans.insertOne({ userId: new ObjectId(req.session.userId), ...newPlan });
- req.session.errMessage = "";
- res.redirect('/plans');
+ await plans.insertOne(planToInsert);
+ res.status(status.Ok).json({ success: true, message: "Plan created successfully." });
}
catch (err) {
console.error("Error saving plan:", err);
- req.session.errMessage = "An error occurred while saving your plan. Please try again.";
- res.status(status.InternalServerError).redirect("/newPlan");
+ res.status(status.InternalServerError).json({ success: false, message: "An error occurred while saving your plan. Please try again." });
+ }
+ });
+
+ router.delete('/plans/:id', async (req, res) => {
+ const planId = req.params.id;
+ const plan = await plans.findOne({ _id: new ObjectId(planId) });
+ if(!plan) {
+ console.log(`Plan not found with ID: ${planId}`);
+ res.status(status.NotFound).json({ success: false, message: "Plan not found or you do not have permission to delete it." });
+ return;
+ }
+ if(plan.userId.toString() !== req.session.userId) {
+ console.log(`User ${req.session.userId} does not have permission to delete plan ${planId}`);
+ res.status(status.Forbidden).json({ success: false, message: "You do not have permission to delete this plan." });
+ return;
+ }
+ try {
+ await plans.deleteOne({ _id: new ObjectId(planId) });
+ res.status(status.Ok).json({ success: true, message: "Plan deleted successfully." });
+ }
+ catch (err) {
+ console.error("Error deleting plan:", err);
+ res.status(status.InternalServerError).json({ success: false, message: "An error occurred while deleting your plan. Please try again." });
+ }
+ });
+
+ router.get('/plans/:id/edit', async (req, res) => {
+ const planId = req.params.id;
+ const plan = await plans.findOne({ _id: new ObjectId(planId) });
+ if(!plan) {
+ console.log(`Plan not found with ID: ${planId}`);
+ res.status(status.NotFound).json({ success: false, message: "Plan not found or you do not have permission to edit it." });
+ return;
+ }
+ if(plan.userId.toString() !== req.session.userId) {
+ console.log(`User ${req.session.userId} does not have permission to edit plan ${planId}`);
+ res.status(status.Forbidden).json({ success: false, message: "You do not have permission to edit this plan." });
+ return;
+ }
+ res.render('editPlan', {
+ user: req.session.user,
+ plan: plan,
+ geoData: req.session.geoData
+ });
+ });
+
+ router.post('/plans/:id/edit', async (req, res) => {
+ const planId = req.params.id;
+ const plan = await plans.findOne({ _id: new ObjectId(planId) });
+ if(!plan) {
+ console.log(`Plan not found with ID: ${planId}`);
+ res.status(status.NotFound).json({ success: false, message: "Plan not found or you do not have permission to edit it." });
+ return;
+ }
+ if(plan.userId.toString() !== req.session.userId) {
+ console.log(`User ${req.session.userId} does not have permission to edit plan ${planId}`);
+ res.status(status.Forbidden).json({ success: false, message: "You do not have permission to edit this plan." });
+ return;
+ }
+
+ const planSchema = joi.object({
+ name: joi.string().min(3).max(100).required(),
+ retirementAge: joi.number().min(18).max(120).required(),
+ retirementExpenses: joi.number().min(0).required(),
+ retirementAssets: joi.number().min(0).required(),
+ retirementLiabilities: joi.number().min(0).required(),
+ });
+
+ // Object for Joi validation - only fields from req.body
+ const dataToValidate = {
+ name: req.body.name,
+ retirementAge: Number(req.body.retirementAge),
+ retirementExpenses: parseFloat(req.body.retirementExpenses),
+ retirementAssets: parseFloat(req.body.retirementAssets),
+ retirementLiabilities: parseFloat(req.body.retirementLiabilities),
+ };
+
+ const { error, value } = planSchema.validate(dataToValidate);
+ if (error) {
+ console.error("Plan validation error:", error.details);
+ const errorMessage = "Invalid input: " + error.details.map(d => d.message.replace(/"/g, '')).join(', ');
+ res.status(status.BadRequest).json({ success: false, message: errorMessage });
+ return;
+ }
+
+ const progressCalculated = await calculateProgress(value, assets, users, req.session.userId);
+
+ const planToSet = {
+ name: value.name,
+ retirementAge: value.retirementAge,
+ retirementExpenses: value.retirementExpenses,
+ retirementAssets: value.retirementAssets,
+ retirementLiabilities: value.retirementLiabilities,
+ progress: progressCalculated.percentage,
+ };
+ try {
+ await plans.updateOne(
+ { _id: new ObjectId(planId), userId: new ObjectId(req.session.userId) },
+ { $set: planToSet }
+ );
+ await updateProgress(plans, assets, users, req.session.user._id);
+ res.status(status.Ok).json({ success: true, message: "Plan updated successfully." });
+ }
+ catch (err) {
+ console.error("Error updating plan:", err);
+ res.status(status.InternalServerError).json({ success: false, message: "An error occurred while updating your plan. Please try again." });
}
});
@@ -353,17 +476,17 @@ module.exports = (middleware, users, plans, assets) => {
router.post("/updateAccount", async (req, res) => {
const accountSchema = joi.object({
- email: joi.string().email(),
+ email: joi.string().email({ minDomainSegments: 2, tlds: { allow: true } }),
name: joi.string().pattern(new RegExp('^[a-zA-Z]+$')).max(20),
- password: joi.string().alphanum().max(20).min(8),
- repassword: joi.string().alphanum().max(20).min(8),
+ password: joi.string().max(20).min(8),
+ repassword: joi.string().max(20).min(8),
});
const valid = accountSchema.validate(req.body);
- if (valid.err) {
- req.session.errMessage = "Invalid input",
- res.status(status.BadRequest);
+ if (valid.error) {
+ req.session.errMessage = "Invalid input:" + valid.error.details.map(d => d.message.replace(/"/g, '')).join(', ');
+ res.status(status.BadRequest);
return res.redirect("/profile");
}
@@ -415,6 +538,7 @@ module.exports = (middleware, users, plans, assets) => {
return res.status(status.Ok).redirect("/profile");
});
+
}).catch(err => {
console.error("Error updating account in database:", err);
req.session.errMessage = "An error occurred while saving your information. Please try again.";
@@ -509,9 +633,9 @@ module.exports = (middleware, users, plans, assets) => {
const valid = assetSchema.validate(req.body);
- if (valid.err) {
- req.session.errMessage = "Invalid input",
- res.status(status.BadRequest);
+ if (valid.error) {
+ req.session.errMessage = "Invalid input:" + valid.error.details.map(d => d.message.replace(/"/g, '')).join(', ');
+ res.status(status.BadRequest);
return res.redirect("/assets");
}
@@ -527,6 +651,7 @@ module.exports = (middleware, users, plans, assets) => {
newAsset.value = newAsset.quantity * newAsset.price;
newAsset.name = `${newAsset.ticker} Stock`;
}
+ if (type == "other" && newAsset.year != "") newAsset.year = parseInt(newAsset.year);
newAsset.value = parseFloat(newAsset.value);
newAsset.icon = type == "stock" ? "Stock" : type == "saving" ? "Coins" : newAsset.icon;
@@ -554,9 +679,9 @@ module.exports = (middleware, users, plans, assets) => {
const valid = assetSchema.validate(req.body);
- if (valid.err) {
- req.session.errMessage = "Invalid input",
- res.status(status.BadRequest);
+ if (valid.error) {
+ req.session.errMessage = "Invalid input:" + valid.error.details.map(d => d.message.replace(/"/g, '')).join(', ');
+ res.status(status.BadRequest);
return res.redirect("/assets");
}
@@ -573,6 +698,7 @@ module.exports = (middleware, users, plans, assets) => {
update.value = update.quantity * update.price;
update.name = `${update.ticker} Stock`;
}
+ if (type == "other" && update.year != "") update.year = parseInt(update.year);
update.value = parseFloat(update.value);
delete update.id;
delete update.userId;
diff --git a/src/util/calculations.js b/src/util/calculations.js
index 19d3222..148b319 100644
--- a/src/util/calculations.js
+++ b/src/util/calculations.js
@@ -12,7 +12,7 @@ async function calculatePlanProgress(plans, assets, userId) {
try {
const userAssets = await assets.find({ userId: new ObjectId(userId) }).toArray();
- const totalUserAssetValue = userAssets.reduce((total, asset) => total + asset.value, 0);
+ const totalUserAssetValue = await calculateTotalAssetValue(userAssets);
let percentage = 0;
if (plans.retirementAssets > 0) {
@@ -42,6 +42,41 @@ async function updatePlanProgressInDB(planId, percentage, plans) {
}
}
+async function calculateTotalAssetValue(assets) {
+ if (!assets || typeof assets.find !== 'function') {
+ console.error("Error with the assets collection");
+ return 0;
+ }
+ try {
+ let totalAssetValue = 0;
+ const today = new Date();
+
+ for (const asset of assets) {
+ if (asset.icon === "Motorcycle" || asset.icon === "Car") {
+ if (asset.value < 1000) {
+ totalAssetValue += asset.value;
+ } else {
+ const assetYear = new Date(asset.year).getFullYear();
+ const age = today.getFullYear() - assetYear;
+ let depreciatedValue = asset.value * Math.pow(0.85, age);
+
+ if (depreciatedValue < 1000) {
+ totalAssetValue += 1000;
+ } else {
+ totalAssetValue += depreciatedValue;
+ }
+ }
+ } else {
+ totalAssetValue += asset.value;
+ }
+ }
+ return totalAssetValue;
+ } catch (err) {
+ console.error("Error in calculateTotalAssetValue:", err);
+ return 0;
+ }
+}
+
async function calculateProgress(plan, assets, users, userId) {
if (!plan || typeof plan !== 'object') {
@@ -63,19 +98,22 @@ async function calculateProgress(plan, assets, users, userId) {
try {
const userAssets = await assets.find({ userId: new ObjectId(userId) }).toArray();
- const totalUserAssetValue = userAssets.reduce((total, asset) => total + asset.value, 0);
+ const totalUserAssetValue = await calculateTotalAssetValue(userAssets);
const totalUserPlanValue = plan.retirementAssets;
+
const userDoc = await users.findOne({ _id: new ObjectId(userId) });
+
if (!userDoc || !userDoc.dob) {
- console.error("User document or DOB not found for userId:", userId);
- return { monthlyInvestment: NaN, totalCostOfRetirement: NaN, monthsUntilRetirement: NaN, yearsRetired: NaN, percentage: NaN };
+ console.error("[calculateProgress] User document or DOB not found for userId:", userId);
+ // Ensure a structured return even on error to avoid undefined.progress issues
+ return { monthlyInvestment: NaN, totalCostOfRetirement: NaN, monthsUntilRetirement: NaN, yearsRetired: NaN, yearsUntilRetirement: NaN, percentage: NaN };
}
const userDob = new Date(userDoc.dob);
if (isNaN(userDob.getTime())) {
- console.error("userDob is an invalid date. Aborting calculation.");
- return { monthlyInvestment: NaN, totalCostOfRetirement: NaN, monthsUntilRetirement: NaN, yearsRetired: NaN, percentage: NaN };
+ console.error("[calculateProgress] userDob is an invalid date. Aborting calculation.");
+ return { monthlyInvestment: NaN, totalCostOfRetirement: NaN, monthsUntilRetirement: NaN, yearsRetired: NaN, yearsUntilRetirement: NaN, percentage: NaN };
}
const today = new Date();
@@ -90,6 +128,10 @@ async function calculateProgress(plan, assets, users, userId) {
const monthlyInvestment = (totalUserPlanValue - totalUserAssetValue + totalCostOfRetirement) / monthsUntilRetirement;
const percentageCalculated = (totalUserAssetValue / (totalUserPlanValue + totalCostOfRetirement)) * 100;
+
+ if (monthsUntilRetirement <= 0) {
+ return { monthlyInvestment: 0, totalCostOfRetirement: totalCostOfRetirement, monthsUntilRetirement: 0, yearsRetired: yearsRetired, yearsUntilRetirement: 0, percentage: 0 };
+ }
const progress = {};
@@ -104,10 +146,23 @@ async function calculateProgress(plan, assets, users, userId) {
} catch (err) {
console.error("Error in calculateProgress:", err);
- return;
+ // Ensure a structured return even on error to avoid undefined.progress issues
+ return { monthlyInvestment: NaN, totalCostOfRetirement: NaN, monthsUntilRetirement: NaN, yearsRetired: NaN, yearsUntilRetirement: NaN, percentage: NaN };
}
}
+
+async function updateProgress(plans, assets, users, userId) {
+ const userPlansFromDB = await plans.find({ userId: new ObjectId(userId) }).toArray();
+ for (const plan of userPlansFromDB) {
+ const progress = await calculateProgress(plan, assets, users, userId);
+ await updatePlanProgressInDB(plan._id, progress.percentage, plans);
+ }
+
+ const updatedUserPlans = await plans.find({ userId: new ObjectId(userId) }).toArray();
+ return updatedUserPlans;
+ }
-module.exports = { calculatePlanProgress, updatePlanProgressInDB, calculateProgress};
\ No newline at end of file
+
+module.exports = { calculatePlanProgress, updatePlanProgressInDB, calculateTotalAssetValue, calculateProgress, updateProgress};
\ No newline at end of file
diff --git a/src/views/assets.ejs b/src/views/assets.ejs
index 5397a5f..cd1e212 100644
--- a/src/views/assets.ejs
+++ b/src/views/assets.ejs
@@ -10,26 +10,29 @@
-
+
-
Assets:
- <%= assets.length %>
+ Assets:
+ <%= assets.length %>
-
Total Value:
-
+ Total Value:
+
- $<%= assets.reduce((total, e) => total + e.value, 0).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) %>
+ <%- new Intl.NumberFormat('en-US', { style: 'currency', currency: geoData.currency ? geoData.currency : 'CAD' }).format(totalUserAssetValue) %>
-
+
Back to Dashboard
diff --git a/src/views/signup.ejs b/src/views/signup.ejs
index 212f2a8..9183107 100644
--- a/src/views/signup.ejs
+++ b/src/views/signup.ejs
@@ -6,27 +6,28 @@