Assets:
+<%= assets.length %>
+Total Value:
++ + $<%= assets.reduce((total, e) => total + e.value, 0).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) %> +
++ + +
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") %>
Assets:
+ <%= assets.length %>
+ Total Value:
+
+
+ $<%= assets.reduce((total, e) => total + e.value, 0).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) %>
+
+
+
+
+
- Retirement goal
-- Make this percent bar or graph or something than last - week -
-- Retirement goal
-- Make this percent bar or graph or something than last - week -
-- Retirement goal
-+ Retirement goal
++ Make this percent bar or graph or something + than last + week +
++ Retirement goal
++ Make this percent bar or graph or something + than last + week +
++ Retirement goal
++ Make this percent bar or graph or something + than last + week +
+- Make this percent bar or graph or something than last - week -
+