diff --git a/app.js b/app.js index 9da8e44..cf8874e 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ const MongoStore = require("connect-mongo"); const session = require("express-session"); const express = require('express'); const path = require('path'); +const bcrypt = require('bcrypt'); const joi = require('joi'); require('dotenv').config(); @@ -34,11 +35,14 @@ app.use(express.json()); const { connectMongo, getCollection } = require("./src/database/connection"); let users; +let assets; +let plans; async function initDatabase() { const db = await connectMongo(mongoURI, database); // For any collection, init here users = await getCollection(db, "users"); + assets = await getCollection(db, "assets"); plans = await getCollection(db, "plans"); } @@ -46,7 +50,7 @@ async function initDatabase() { app.get('/', (req, res) => { if (!req.session.errMessage) req.session.errMessage = ""; - res.render('dashboard'); + res.render('landing'); return res.status(status.Ok); }); @@ -76,9 +80,6 @@ app.get('/forgotPassword', (req, res) => { return res.status(status.Ok); }); -app.get('reset', (req, res) => { - return res.render('resetPass'); -}) // Reset with token given to user via email app.get('/reset/:token', async (req, res) => { @@ -96,7 +97,7 @@ app.get('/reset/:token', async (req, res) => { const error = req.session.error; req.session.error = ''; - res.render('resetPassword', { + res.render('resetPass', { token: token, errMessage: error, }); @@ -150,6 +151,12 @@ app.post('/api/location', async (req, res) => { res.json(data); }); +// 404 handler - keep the actual notFound route please +app.get('/notFound', (req, res) => { + res.render('notFound'); + return res.status(status.NotFound); +}); + // Initialize database and start app initDatabase().then(() => { console.log("Successfully connected to MongoDB"); @@ -158,13 +165,14 @@ initDatabase().then(() => { app.use(require("./src/auth/authentication")(users)); app.use(require('./src/auth/forgotPass')(users)); + // Import middleware & apply to user routes - const middleware = require("./src/auth/middleware")(users, plans); - app.use(require('./src/router/user')(middleware, users, plans)); + const middleware = require("./src/auth/middleware")(users); + app.use(require('./src/router/user')(middleware, users, plans, assets)); // 404 handler app.get('/*splat', (req, res) => { - res.send('404 Not Found'); + res.render('notFound'); return res.status(status.NotFound); }); diff --git a/src/auth/authentication.js b/src/auth/authentication.js index 261b5a4..21ba26a 100644 --- a/src/auth/authentication.js +++ b/src/auth/authentication.js @@ -3,6 +3,10 @@ const bcrypt = require('bcrypt'); const joi = require("joi"); const salt = 12; +/** + * @param {MongoClient.collection} users db collection + * @returns {express.Router} authentication router + */ module.exports = (users) => { const router = require("express").Router(); @@ -76,6 +80,7 @@ module.exports = (users) => { email: req.body.email, name: req.body.name, password: hashedPassword, + financialData: false, }).then((results, err) => { if (err) { console.error(err); diff --git a/src/auth/middleware.js b/src/auth/middleware.js index 8eeaff2..bf1ed0a 100644 --- a/src/auth/middleware.js +++ b/src/auth/middleware.js @@ -1,28 +1,40 @@ const status = require("../util/statuses"); const session = require("express-session"); +// Get all user routes names +let userRouter = require("../router/user")((req, res, next) => next(), null, null, null); +userRouter.stack.shift(); +const userRoutes = userRouter.stack.map((layer) => layer.route.path.split("/")[1]); + /** - * createMiddleware returns a middleware function for express. - * @param {MongoClient.collection} users - * @return {async function} - */ + * @param {MongoClient.collection} users db collection + * @returns {async function} middleware handler function +*/ const createMiddleware = (users) => { return async (req, res, next) => { + // Redirect not found pages to 404 page + if (!userRoutes.includes(req.url.substring(1))) { + return res.status(status.NotFound).redirect("/notFound"); + } + if (!req.session.authenticated || !req.session.email) { req.session.errMessage = "Please login to view that resource"; res.redirect("/login"); return res.status(status.Unauthorized); } - let user = await users.findOne({ "email": req.session.email }).then((user) => user); - - if (!user) { - req.session.errMessage = "User not found"; - res.redirect("/login"); - return res.status(status.Unauthorized); + if (!req.session.user) { + let user = await users.findOne({ "email": req.session.email }).then((user) => user); + + if (!user) { + req.session.errMessage = "User not found"; + res.redirect("/login"); + return res.status(status.Unauthorized); + } + + req.session.user = user; } - - req.user = user; + next(); }; } diff --git a/src/public/scripts/assetManager.js b/src/public/scripts/assetManager.js new file mode 100644 index 0000000..86ddd08 --- /dev/null +++ b/src/public/scripts/assetManager.js @@ -0,0 +1,99 @@ +const assetForms = [ + "create-other-asset-form", + "create-saving-asset-form", + "create-stock-asset-form" +]; + +/** + * resetAll asset creation forms + */ +function resetAll() { + document.getElementById("create-other-asset-form").reset(); + document.getElementById("create-saving-asset-form").reset(); + document.getElementById("create-stock-asset-form").reset(); +} + +/** + * selectAssetForm changes which form is displayed + * to create specified assets. + * @param {string} assetFormId + */ +function selectAssetForm(assetFormId) { + for (let i = 0; i < assetForms.length; i++) { + if (assetForms[i] == assetFormId) { + document.getElementById(assetFormId).style.display = "block"; + } else { + document.getElementById(assetForms[i]).style.display = "none"; + } + } +} + +/** + * resetRadio to default to other asset type. + */ +function resetRadio() { + document.getElementById("asset-other").checked = true; + document.getElementById("asset-saving").checked = false; + document.getElementById("asset-stock").checked = false; +} + +const assetKeys = { + other: [ + "name", + "value", + "description", + "purchaseDate", + ], + saving: [ + "name", + "value", + ], + stock: [ + "ticker", + "price", + "quantity", + "purchaseDate", + ], +} + +/** + * lockAsset prevents edits to asset view modal + * @param {string} assetId + */ +function lockAsset(assetId) { + document.getElementById(`${assetId}-form`).reset(); + + const type = document.getElementById(`type-${assetId}`).value; + + assetKeys[type].forEach((key) => { + document.getElementById(`${key}-${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"); + + document.getElementById(`edit-${assetId}`).innerHTML = "Edit"; + document.getElementById(`edit-${assetId}`).onclick = () => { unlockAsset(assetId) }; +} + +/** + * unlockAsset allows edits to asset view modal + * @param {string} assetId + */ +function unlockAsset(assetId) { + const type = document.getElementById(`type-${assetId}`).value; + + assetKeys[type].forEach((key) => { + document.getElementById(`${key}-${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"); + + document.getElementById(`edit-${assetId}`).innerHTML = "Cancel changes"; + document.getElementById(`edit-${assetId}`).onclick = () => { lockAsset(assetId) }; +} + +resetRadio(); diff --git a/src/router/user.js b/src/router/user.js index 39fb28b..5cde315 100644 --- a/src/router/user.js +++ b/src/router/user.js @@ -1,38 +1,88 @@ const status = require("../util/statuses"); -const bcrypt = require('bcrypt'); +const ObjectId = require('mongodb').ObjectId; +const bcrypt = require("bcrypt"); const joi = require("joi"); const salt = 12; -module.exports = (middleware, users, plans) => { +/** + * getAssetSchema returns correct joi object + * to validate req.body depending on asset type + * @param {string} type of asset + * @returns {joi.object} schema + */ +const getAssetSchema = (type) => { + let assetSchema; + + switch (type) { + case "other": + assetSchema = joi.object({ + ownerId: joi.string().alphanum().required(), + type: joi.string().valid("other", "stock", "saving").required(), + name: joi.string().alphanum().min(3).max(30).required(), + value: joi.number().min(0).required(), + purchaseDate: joi.date().required(), + description: joi.string().alphanum().max(240), + id: joi.string().alphanum(), // May be passed when updating existing asset + }); + break; + case "saving": + assetSchema = joi.object({ + ownerId: joi.string().alphanum().required(), + type: joi.string().valid("other", "stock", "saving").required(), + name: joi.string().alphanum().min(3).max(30).required(), + value: joi.number().min(0).required(), + id: joi.string().alphanum(), // May be passed when updating existing asset + }); + break; + case "stock": + assetSchema = joi.object({ + ownerId: joi.string().alphanum().required(), + type: joi.string().valid("other", "stock", "saving").required(), + ticker: joi.string().alphanum().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 + }); + break; + default: + return null; + }; + + return assetSchema; +} + +/** + * @param {function} middleware handler + * @param {MongoClient.collection} users db collection + * @param {MongoClient.collection} assets db collection + * @returns {express.Router} user protected routes router + */ +module.exports = (middleware, users, plans, assets) => { const router = require("express").Router(); router.use(middleware); router.get('/home', async (req, res) => { - res.render('home', { user: req.user }); + res.render('dashboard', { user: req.session.user }); return res.status(status.Ok); }); - router.get('/assets', (req, res) => { - res.render('assets', { user: req.user }); + router.get('/assets', async (req, res) => { + let userAssets = await assets.find({ "ownerId": req.session.user._id }).toArray(); + res.render('assets', { user: req.session.user, errMessage: req.session.errMessage, assets: userAssets }); return res.status(status.Ok); }); router.get('/plans', async (req, res) => { - if (!req.session.email) { - return res.status(status.Unauthorized).redirect('/login'); - } - try { - - // console.log(req.user.email); - const userPlans = await plans.find({ userEmail: req.user.email }).toArray(); + // console.log(new ObjectId(req.session.user._id)); + const userPlans = await plans.find({ userId: new ObjectId(req.session.user._id) }).toArray(); // console.log(userPlans); res.render('plans', { - user: req.user, + user: req.session.user, plans: userPlans }); - } catch (err) { console.error("Error fetching plans:", err); req.session.errMessage = "Could not load your plans. Please try again."; @@ -40,17 +90,48 @@ module.exports = (middleware, users, plans) => { } }); + router.get('/plans/:id', async (req, res) => { + + try { + const planId = req.params.id; + + + 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.user._id), _id: new ObjectId(planId) }); + + if (!plan) { + console.log(`Plan not found with ID: ${planId} for user: ${req.session.user.email}`); + req.session.errMessage = "Plan not found or you do not have permission to view it."; + return res.status(status.NotFound).redirect('/plans'); + } + // console.log("Found plan:", plan); + res.render('planDetail', { + user: req.session.user, + plan: plan + }); + + } catch (err) { + console.error("Error fetching plan:", err); + req.session.errMessage = "Could not load your plan. Please try again."; + res.status(status.InternalServerError).redirect('/home'); + } + }); + router.get('/newPlan', (req, res) => { + if (!req.session.user.financialData) { + 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.user, errMessage: errMessage }); + res.render('newPlan', { user: req.session.user, errMessage: errMessage }); }); router.post('/newPlan', async (req, res) => { - if (!req.session.email) { - return res.status(status.Unauthorized).redirect('/login'); - } - const planSchema = joi.object({ name: joi.string().min(3).max(100).required(), retirementAge: joi.number().min(18).max(120).required(), @@ -69,7 +150,7 @@ module.exports = (middleware, users, plans) => { return; } const newPlan = { - userEmail: req.user.email, + userId: new ObjectId(req.session.user._id), name: value.name, retirementAge: value.retirementAge, retirementExpenses: value.retirementExpenses, @@ -79,7 +160,7 @@ module.exports = (middleware, users, plans) => { }; try { - await plans.insertOne(newPlan); + await plans.insertOne({ userId: new ObjectId(req.session.user._id), ...newPlan }); req.session.errMessage = ""; res.redirect('/plans'); } @@ -91,24 +172,24 @@ module.exports = (middleware, users, plans) => { }); router.get('/more', (req, res) => { - res.render('more', { user: req.user }); + res.render('more', { user: req.session.user }); return res.status(status.Ok); }); router.get('/profile', (req, res) => { - res.render('profile', { user: req.user, errMessage: req.session.errMessage }); + res.render('profile', { user: req.session.user, errMessage: req.session.errMessage }); return res.status(status.Ok); }); router.get('/settings', (req, res) => { - res.render('settings', { user: req.user }); + res.render('settings', { user: req.session.user }); return res.status(status.Ok); }); router.get('/questionnaire', (req, res) => { const errMessage = req.session.errMessage; req.session.errMessage = ""; - res.render('questionnaire', { user: req.user, errMessage: errMessage }); + res.render('questionnaire', { user: req.session.user, errMessage: errMessage }); }); router.post('/questionnaire', (req, res) => { @@ -135,7 +216,7 @@ module.exports = (middleware, users, plans) => { } users.updateOne( - { email: req.session.email }, + { _id: new ObjectId(req.session.user._id) }, { $set: { financialData: true, @@ -150,15 +231,16 @@ module.exports = (middleware, users, plans) => { } ).then((result) => { if (result.matchedCount === 0) { - console.log(`User not found during questionnaire update: ${req.session.email}`); + console.log(`User not found during questionnaire update: ${req.session.user.email}`); req.session.errMessage = "User session invalid. Please log in again."; res.status(status.NotFound).redirect("/login"); return; } if (result.modifiedCount === 0 && result.matchedCount === 1) { - console.log(`User questionnaire data unchanged (already up-to-date): ${req.session.email}`); + console.log(`User questionnaire data unchanged (already up-to-date): ${req.session.user.email}`); } + req.session.user.financialData = true; req.session.errMessage = ""; res.status(status.Ok).redirect("/home"); @@ -186,7 +268,6 @@ module.exports = (middleware, users, plans) => { } let update = { - // email: req.body.email, name: req.body.name, }; @@ -222,5 +303,134 @@ module.exports = (middleware, users, plans) => { }); }); + router.post("/createAsset", async (req, res) => { + // Create asset, each asset has different data structure based on type + const type = req.body.type; + const assetSchema = getAssetSchema(type); + + if (!assetSchema) { + console.error("Modified asset type, rejected"); + req.session.errMessage = "Invalid input"; + return res.status(status.BadRequest).redirect("/assets"); + } + + const valid = assetSchema.validate(req.body); + + if (valid.err) { + req.session.errMessage = "Invalid input", + res.status(status.BadRequest); + return res.redirect("/assets"); + } + + let newAsset = { + ...req.body, + updatedAt: new Date(), + }; + + if (type == "stock") { + newAsset.quantity = parseInt(newAsset.quantity); + newAsset.price = parseFloat(newAsset.price) + newAsset.value = newAsset.quantity * newAsset.price; + newAsset.name = `${newAsset.ticker} Stock`; + } + newAsset.value = parseFloat(newAsset.value); + + assets.insertOne(newAsset, (err, _) => { + if (err) { + console.error("Error creating asset: ", err); + req.session.errMessage = "Internal server error"; + return res.status(status.InternalServerError).redirect("/assets"); + } + }); + + req.session.errMessage = ""; + return res.status(status.Ok).redirect("/assets"); + }); + + router.post("/updateAsset", async (req, res) => { + const type = req.body.type; + const assetSchema = getAssetSchema(type); + + if (!assetSchema) { + console.error("Modified asset type, rejected"); + req.session.errMessage = "Invalid input"; + return res.status(status.BadRequest).redirect("/assets"); + } + + const valid = assetSchema.validate(req.body); + + if (valid.err) { + req.session.errMessage = "Invalid input", + res.status(status.BadRequest); + return res.redirect("/assets"); + } + + if (req.body.ownerId != req.session.user._id) { + req.session.errMessage = "Cannot change asset owner", + res.status(status.BadRequest); + return res.redirect("/assets"); + } + + let update = { ...req.body, updatedAt: new Date() }; + if (type == "stock") { + update.quantity = parseInt(update.quantity); + update.price = parseFloat(update.price) + update.value = update.quantity * update.price; + update.name = `${update.ticker} Stock`; + } + update.value = parseFloat(update.value); + delete update.id + + assets.updateOne( + { "_id": new ObjectId(req.body.id) }, + { $set: update }, + ).then((result) => { + if (result.matchedCount === 0) { + console.log(`Asset not found: ${req.body.id}`); + req.session.errMessage = "Unable to update asset"; + return res.status(status.NotFound).redirect("/assets"); + } + if (result.modifiedCount === 0 && result.matchedCount === 1) { + console.log(`Asset data unchanged (already up-to-date): ${req.body.id}`); + } + + req.session.errMessage = ""; + return res.status(status.Ok).redirect("/assets"); + + }).catch((err) => { + console.error("Error updating asset: ", err); + req.session.errMessage = "An error occurred while saving your information. Please try again."; + return res.status(status.InternalServerError).redirect("/assets"); + }); + }); + + router.post("/deleteAsset", (req, res) => { + const id = new ObjectId(req.body.id); + + assets.deleteOne( + { "_id": id } + ).then((result) => { + if (result.deletedCount === 0) { + console.log(`Asset not found: ${req.body.id}`); + req.session.errMessage = "Unable to delete asset"; + return res.status(status.NotFound).redirect("/assets"); + } + + if (!result.acknowledged) { + console.error("Error updating asset: ", err); + req.session.errMessage = "An error occurred while saving your information. Please try again."; + return res.status(status.InternalServerError).redirect("/assets"); + } + + req.session.errMessage = ""; + return res.status(status.Ok).redirect("/assets"); + + }).catch((err) => { + console.error("Error updating asset: ", err); + req.session.errMessage = "An error occurred while saving your information. Please try again."; + return res.status(status.InternalServerError).redirect("/assets"); + }); + }); + return router; }; diff --git a/src/views/assets.ejs b/src/views/assets.ejs index a926f36..630cab2 100644 --- a/src/views/assets.ejs +++ b/src/views/assets.ejs @@ -2,8 +2,345 @@ <%- include("./partials/header") %>
- The Assets Page + + + +
+

Create New Asset

+
+ +
+
+ +
+ +
+ + + +
+
+ + +
+ + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + +
+ + <% if (errMessage != "") { %> +
<%= errMessage %>
+ <% } %> + + +
+
+
+

Assets:

+

<%= assets.length %>

+
+
+

Total Value:

+

+ + $<%= assets.reduce((total, e) => total + e.value, 0).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) %> +

+
+
+ +
+ + Back to Dashboard + + +
+
+ +
+ + +
+ <% if (assets.length == 0) { %> +
+ You have no assets, create a new asset. +
+ <% } %> + <% assets.forEach((asset) => { %> + +
+ +
+ + + +
+ +
+
+ +
+ +

Are you sure you want to delete this item?

+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ ICON +
+
+ +
+
+ +
+ + + + + <% if (asset.type != "stock") { %> + + + <% } else { %> + + + <% } %> + +
+ + <% if (asset.type != "stock") { %> + + + <% } else { %> + + + + + + <% } %> + + <% if (asset.type == "other") { %> + + + <% } %> + + <% if (asset.type != "saving") { %> + + + <% } %> + +
+ Last Modified: <%= asset.updatedAt %> +
+ +
+ +
+
+
+ <% }) %> +
+
+ + + + <%- include("./partials/navBar") %> <%- include("./partials/footer") %> \ No newline at end of file diff --git a/src/views/dashboard.ejs b/src/views/dashboard.ejs index 182184e..106e60d 100644 --- a/src/views/dashboard.ejs +++ b/src/views/dashboard.ejs @@ -1,99 +1,110 @@ <%- include("./partials/fileHeader") %> - <%- include("./partials/header") %> + <%- include("./partials/header") %> -
- -
- - -
- -
-
- -
- - - -
-

- Retirement goal

-

- $53k

+
+

Welcome: <%= user.name %> +

+ +
+ + + Create new plan +
-
-

- Make this percent bar or graph or something  than last - week -

-
-
-
-
-

- Retirement goal

-

- $53k

-
-
-

- Make this percent bar or graph or something  than last - week -

-
-
-
- - - - - - - - - - - - - - - - - - - - - - - -
-
-
-

- Retirement goal

-

- $53k

+ +
+
+ +
+ + + +
+

+ Retirement goal

+

+ $53k

+
+
+

+ Make this percent bar or graph or something +  than last + week +

+
+
+
+
+

+ Retirement goal

+

+ $53k

+
+
+

+ Make this percent bar or graph or something +  than last + week +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

+ Retirement goal

+

+ $53k

+
+
+

+ Make this percent bar or graph or something +  than last + week +

+
+
+
+
-
-

- Make this percent bar or graph or something  than last - week -

+
-
-
-
-
-
-
-
+ -<%- include("./partials/navBar") %> - <%- include("./partials/footer") %> + <%- include("./partials/navBar") %> + <%- include("./partials/footer") %> diff --git a/src/views/home.ejs b/src/views/home.ejs deleted file mode 100644 index 405e232..0000000 --- a/src/views/home.ejs +++ /dev/null @@ -1,14 +0,0 @@ -<%- include("./partials/fileHeader") %> -<%- include("./partials/header") %> - -
- Welcome: <%= user.email %> - The Dashboard Page - <% if(user.authenticated) { %> - <%= user.email %> - <%= user.errMessage %> - <% } %> -
- -<%- include("./partials/navBar") %> -<%- include("./partials/footer") %> diff --git a/src/views/newPlan.ejs b/src/views/newPlan.ejs index 40b409b..a05dd1a 100644 --- a/src/views/newPlan.ejs +++ b/src/views/newPlan.ejs @@ -2,9 +2,9 @@ <%- include("./partials/header") %>
-

Welcome: <%= user.email %>

+

Welcome: <%= user.name %>

-

Financial Questionnaire

+

Retirement Plan

diff --git a/src/views/notFound.ejs b/src/views/notFound.ejs new file mode 100644 index 0000000..2e783bc --- /dev/null +++ b/src/views/notFound.ejs @@ -0,0 +1,24 @@ +<%- include("./partials/fileHeader") %> +<%- include("./partials/headerStart") %> + +
+
+
+

404 - Not found

+

It appears you stumbled accross a misleading page.

+ +
+
+
+ +<%- include("./partials/footer") %> diff --git a/src/views/partials/footer.ejs b/src/views/partials/footer.ejs index 0c2379c..fabc132 100644 --- a/src/views/partials/footer.ejs +++ b/src/views/partials/footer.ejs @@ -1,5 +1,5 @@ -
+ +<%- include("./partials/navBar") %> +<%- include("./partials/footer") %> diff --git a/src/views/plans.ejs b/src/views/plans.ejs index 10a6ca1..ce7ca80 100644 --- a/src/views/plans.ejs +++ b/src/views/plans.ejs @@ -2,13 +2,13 @@ <%- include("./partials/header") %>
-

Welcome: <%= user.email %>

+

Welcome: <%= user.name %>

My Retirement Plans

-
New Plan
+
New Plan

<% plans.forEach(plan => { %> - +
<%= plan.name %>