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.

+

wallet icon

+

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 + + + Cancel + +
+ +
+ + + + +<%- include('./partials/navBar') %> +<%- include('./partials/scriptLoader') %> +<%- include('./partials/footer') %> \ No newline at end of file diff --git a/src/views/forgotPass.ejs b/src/views/forgotPass.ejs index a7a656c..af3707f 100644 --- a/src/views/forgotPass.ejs +++ b/src/views/forgotPass.ejs @@ -1,7 +1,7 @@ <%- include("./partials/fileHeader") %> <%- include("./partials/headerStart") %> -
+
@@ -12,7 +12,7 @@
<% } else { %> @@ -31,7 +31,7 @@ <% if (!reset) { %>
+ class="border-2 border-blue-600 bg-blue-600 text-white py-2 w-full rounded-md hover:bg-blue-700 hover:border-blue-700 font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">Send Reset Link
<% } %>
diff --git a/src/views/login.ejs b/src/views/login.ejs index e0c99d0..b929c0a 100644 --- a/src/views/login.ejs +++ b/src/views/login.ejs @@ -6,17 +6,18 @@

Login

-
+
+

* Indicates a required field

- - + +
- - Password * +
<% if (errMessage && errMessage.length > 0) { %> @@ -26,20 +27,20 @@ <% } %>
+ class="border-2 border-blue-600 bg-blue-600 text-white py-2 w-full rounded-md hover:bg-blue-700 hover:border-blue-700 font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">Login
Don't have an account? - Register + Register
diff --git a/src/views/newPlan.ejs b/src/views/newPlan.ejs deleted file mode 100644 index d337639..0000000 --- a/src/views/newPlan.ejs +++ /dev/null @@ -1,41 +0,0 @@ -<%- include("./partials/fileHeader") %> -<%- include("./partials/header") %> - -
-
-

Retirement Plan

-
-
- - -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
-
-
-
- -<%- include("./partials/navBar") %> -<%- include("./partials/scriptLoader") %> -<%- include("./partials/footer") %> \ No newline at end of file diff --git a/src/views/partials/assetDelete.ejs b/src/views/partials/assetDelete.ejs index c783b89..972e8d0 100644 --- a/src/views/partials/assetDelete.ejs +++ b/src/views/partials/assetDelete.ejs @@ -4,7 +4,10 @@
- diff --git a/src/views/partials/assetModify.ejs b/src/views/partials/assetModify.ejs index 82059c8..a35aca4 100644 --- a/src/views/partials/assetModify.ejs +++ b/src/views/partials/assetModify.ejs @@ -8,7 +8,8 @@ style="width: 100%;" onclick="lockAsset('<%= asset._id %>', '<%= asset.icon %>')" type="submit" - class="text-center w-sm py-2 py-2 mx-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer" + class="text-center w-sm py-2 py-2 mx-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white + bg-blue-600 hover:bg-blue-700 focus:outline-none " > Close @@ -30,7 +31,13 @@
<% if (asset.type == "other") { %>
-
<% } else { %> - <% } %> @@ -61,7 +72,7 @@ @@ -70,50 +81,129 @@
- - + + - + <% if (asset.type != "stock") { %> - - + + <% } else { %> - - + + <% } %>
<% if (asset.type != "stock") { %> - - + + + +
+ style="display: none;" + <% } %> + > + + +
<% } else { %> - - + + - - + + <% } %> <% if (asset.type == "other") { %> - + <% } %> <% if (asset.type != "saving") { %> - - + + <% } %>
- Last Modified: <%= asset.updatedAt %> + <% let options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; %> + Last Modified: <%= asset.updatedAt.toLocaleDateString("en-US", options); %>
-
- \ No newline at end of file + diff --git a/src/views/partials/assetPreview.ejs b/src/views/partials/assetPreview.ejs index 4d8c588..831b45e 100644 --- a/src/views/partials/assetPreview.ejs +++ b/src/views/partials/assetPreview.ejs @@ -1,6 +1,10 @@
- -
\ No newline at end of file +
diff --git a/src/views/partials/createAsset.ejs b/src/views/partials/createAsset.ejs index b6624c7..d69543f 100644 --- a/src/views/partials/createAsset.ejs +++ b/src/views/partials/createAsset.ejs @@ -6,26 +6,49 @@
+

* Indicates a required field

@@ -38,7 +61,12 @@
-
- - + + - - + - - + + + + + - +
@@ -92,23 +171,40 @@
- +

<%- new Intl.NumberFormat('en-US', { style: 'currency', currency: geoData.currency ? geoData.currency : 'CAD' }).format(plan.retirementExpenses) %>

@@ -87,10 +87,16 @@

+
+ + +
+ + <%- include("./partials/navBar") %> <%- include("./partials/scriptLoader") %> <%- include("./partials/footer") %> diff --git a/src/views/plans.ejs b/src/views/plans.ejs index adfc5ee..ac8488e 100644 --- a/src/views/plans.ejs +++ b/src/views/plans.ejs @@ -4,7 +4,8 @@

My Retirement Plans

-
New Plan
+ +
<% plans.forEach(plan => { %> @@ -16,9 +17,53 @@ <% }) %>
- + + + + +
+

Create New Retirement Plan

+
+ +
+
+ + +
+
+

* Indicates a required field

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+
+
<%- include("./partials/navBar") %> <%- include("./partials/scriptLoader") %> + <%- include("./partials/footer") %> diff --git a/src/views/profile.ejs b/src/views/profile.ejs index 3be7f54..ee1ce2b 100644 --- a/src/views/profile.ejs +++ b/src/views/profile.ejs @@ -51,24 +51,24 @@

Account settings

- +
- + - + - + - +
- +
@@ -78,7 +78,7 @@

Personal information

<% if (user.financialData) { %> - + <% } %>
@@ -91,17 +91,19 @@ id="dob" type="date" name="dob" - class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-500 disabled:shadow-none" + class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-500 disabled:shadow-none" <% let year = typeof(user.dob) == "object" ? user.dob.getFullYear() : user.dob.split('-')[0]; %> <% let month = typeof(user.dob) == "object" ? user.dob.getMonth() : user.dob.split('-')[1] - 1; %> <% let day = typeof(user.dob) == "object" ? user.dob.getDate() + 1 : user.dob.split('-')[2].split('T')[0];%> <% let d = new Date(parseInt(year), parseInt(month), parseInt(day)); %> value="<%= d.getFullYear() + "-" + ("0"+(d.getMonth()+1)).slice(-2) + "-" + ("0" + d.getDate()).slice(-2); %>" + <% let d1 = new Date(); %> + max="<%= (d1.getFullYear() - 18) + "-" +("0"+(d1.getMonth()+1)).slice(-2) + "-" + ("0" + d1.getDate()).slice(-2); %>" >
- @@ -112,19 +114,19 @@
@@ -132,31 +134,31 @@
- +
- +
- +
- +
- +
<% } else { %>
- Answer Questionnare + Answer Questionnare
<% } %> diff --git a/src/views/questionnaire.ejs b/src/views/questionnaire.ejs index 8cf4cd7..ea34367 100644 --- a/src/views/questionnaire.ejs +++ b/src/views/questionnaire.ejs @@ -2,17 +2,18 @@ <%- include("./partials/header") %>
-

Welcome: <%= user.name %>

Financial Questionnaire

+

* Indicates a required field

- - + + <% let d = new Date(); %> + " type="date" name="dob" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
- - @@ -20,49 +21,49 @@
- +
- - + +
- - + +
- - + +
- - + +
- +
diff --git a/src/views/resetPass.ejs b/src/views/resetPass.ejs index b608e5a..8e7745d 100644 --- a/src/views/resetPass.ejs +++ b/src/views/resetPass.ejs @@ -26,7 +26,7 @@ <% } %>
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 @@

Signup

-
+
+

* Indicates a required field

- - + +
- - + +
- - Password * + - - Re-type Password * +
<% if (errMessage && errMessage.length > 0) { %> @@ -36,11 +37,11 @@ <% } %>
+ class="border-2 border-blue-600 bg-blue-600 text-white py-2 w-full rounded-md hover:bg-blue-700 hover:border-blue-700 font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">Signup
Already have an account? - Login + Login