573 lines
22 KiB
JavaScript
573 lines
22 KiB
JavaScript
const getRates = require("../util/exchangeRate");
|
|
const { calculatePlanProgress, updatePlanProgressInDB } = require("../util/calculations");
|
|
const suggestions = require("../util/suggestions");
|
|
const status = require("../util/statuses");
|
|
const ObjectId = require('mongodb').ObjectId;
|
|
const session = require("express-session");
|
|
const bcrypt = require("bcrypt");
|
|
const path = require("path");
|
|
const joi = require("joi");
|
|
const fs = require("fs");
|
|
const salt = 12;
|
|
|
|
/**
|
|
* 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({
|
|
type: joi.string().valid("other", "stock", "saving").required(),
|
|
icon: joi.string().alphanum().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({
|
|
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({
|
|
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();
|
|
|
|
// Create list of icon filenames
|
|
let icons = fs.readdirSync(path.join(__dirname, "../public/svgs/icons"));
|
|
icons = icons.map((icon) => icon.split(".")[0]);
|
|
|
|
router.use(middleware);
|
|
|
|
router.get('/home', async (req, res) => {
|
|
// if no session with geoData
|
|
if (!req.session.geoData) {
|
|
req.session.geoData = {
|
|
country: null,
|
|
toCurrencyRates: [],
|
|
};
|
|
}
|
|
|
|
res.render('dashboard', { user: req.user, geoData: req.session.geoData });
|
|
|
|
return res.status(status.Ok);
|
|
});
|
|
|
|
router.get('/assets', async (req, res) => {
|
|
let userAssets = await assets.find({ userId: new ObjectId(req.session.userId) }).toArray();
|
|
res.render('assets', {
|
|
user: req.session.user,
|
|
errMessage: req.session.errMessage,
|
|
assets: userAssets,
|
|
geoData: req.session.geoData,
|
|
icons: icons,
|
|
});
|
|
return res.status(status.Ok);
|
|
});
|
|
|
|
router.get('/plans', async (req, res) => {
|
|
try {
|
|
const userPlansFromDB = await plans.find({ userId: new ObjectId(req.session.userId) }).toArray();
|
|
|
|
// Use a for...of loop for proper async/await behavior in series for updates
|
|
for (const plan of userPlansFromDB) {
|
|
const percentage = await calculatePlanProgress(plan, assets, req.session.userId);
|
|
await updatePlanProgressInDB(plan._id, percentage, plans); // Pass the 'plans' collection
|
|
}
|
|
|
|
// Re-fetch plans to get updated progress for rendering
|
|
const updatedUserPlans = await plans.find({ userId: new ObjectId(req.session.userId) }).toArray();
|
|
|
|
res.render('plans', {
|
|
user: req.session.user,
|
|
plans: updatedUserPlans, // Send the most up-to-date plans
|
|
geoData: req.session.geoData
|
|
});
|
|
} catch (err) {
|
|
console.error("Error fetching plans:", err);
|
|
req.session.errMessage = "Could not load your plans. Please try again.";
|
|
res.status(status.InternalServerError).redirect('/home');
|
|
}
|
|
});
|
|
|
|
router.get('/plans/:id', async (req, res) => {
|
|
|
|
try {
|
|
const planId = req.params.id;
|
|
let userAssets = await assets.find({ userId: new ObjectId(req.session.userId) }).toArray();
|
|
|
|
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');
|
|
}
|
|
|
|
// The plan.progress should be up-to-date from the database as it was updated in the /plans route
|
|
// or when assets/plans are modified. If an immediate recalculation for this specific view is absolutely needed,
|
|
// (e.g., if assets were modified without an immediate plan progress update elsewhere),
|
|
// you could do it here:
|
|
// const currentProgress = await calculatePlanProgress(plan, assets, req.session.user._id);
|
|
// plan.progress = currentProgress; // This would only update the 'plan' object for this render, not in DB
|
|
|
|
res.render('planDetail', {
|
|
user: req.session.user,
|
|
plan: plan, // This plan object will have the progress from the database
|
|
geoData: req.session.geoData,
|
|
assets: userAssets,
|
|
suggestions: await suggestions.generateSuggestions(),
|
|
});
|
|
|
|
} 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.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(),
|
|
retirementExpenses: joi.number().min(0).required(),
|
|
retirementAssets: joi.number().min(0).required(),
|
|
retirementLiabilities: joi.number().min(0).required(),
|
|
});
|
|
|
|
const validationOptions = { convert: true, abortEarly: false };
|
|
const { error, value } = planSchema.validate(req.body, validationOptions);
|
|
|
|
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");
|
|
return;
|
|
}
|
|
const newPlan = {
|
|
userId: new ObjectId(req.session.userId),
|
|
name: value.name,
|
|
retirementAge: value.retirementAge,
|
|
retirementExpenses: value.retirementExpenses,
|
|
retirementAssets: value.retirementAssets,
|
|
retirementLiabilities: value.retirementLiabilities,
|
|
progress: "0"
|
|
};
|
|
|
|
try {
|
|
await plans.insertOne({ userId: new ObjectId(req.session.userId), ...newPlan });
|
|
req.session.errMessage = "";
|
|
res.redirect('/plans');
|
|
}
|
|
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");
|
|
}
|
|
});
|
|
|
|
router.get('/pun', async (req, res) => {
|
|
const pun = await suggestions.generatePun();
|
|
return res.status(status.Ok).json({ pun });
|
|
});
|
|
|
|
router.get('/more', (req, res) => {
|
|
res.render('more', {
|
|
user: req.session.user,
|
|
geoData: req.session.geoData
|
|
});
|
|
return res.status(status.Ok);
|
|
});
|
|
|
|
router.get('/profile', (req, res) => {
|
|
res.render('profile', {
|
|
user: req.session.user,
|
|
errMessage: req.session.errMessage,
|
|
geoData: req.session.geoData
|
|
});
|
|
return res.status(status.Ok);
|
|
});
|
|
|
|
router.get('/settings', (req, res) => {
|
|
res.render('settings', {
|
|
user: req.session.user,
|
|
geoData: req.session.geoData
|
|
});
|
|
return res.status(status.Ok);
|
|
});
|
|
|
|
router.get('/questionnaire', (req, res) => {
|
|
const errMessage = req.session.errMessage;
|
|
req.session.errMessage = "";
|
|
res.render('questionnaire', {
|
|
user: req.session.user,
|
|
errMessage: errMessage,
|
|
geoData: req.session.geoData
|
|
});
|
|
});
|
|
|
|
router.post('/questionnaire', (req, res) => {
|
|
const questionnaireSchema = joi.object({
|
|
dob: joi.date().required(),
|
|
education: joi.string().valid('primary', 'secondary', 'tertiary', 'postgraduate').required(),
|
|
maritalStatus: joi.string().valid('single', 'married', 'divorced', 'widowed').required(),
|
|
income: joi.number().min(0).required(),
|
|
expenses: joi.number().min(0).required(),
|
|
assets: joi.number().min(0).required(),
|
|
liabilities: joi.number().min(0).required(),
|
|
});
|
|
|
|
const validationOptions = { convert: true, abortEarly: false };
|
|
const { error, value } = questionnaireSchema.validate(req.body, validationOptions);
|
|
|
|
if (error) {
|
|
console.error("Questionnaire validation error:", error.details);
|
|
req.session.errMessage = "Invalid input: " + error.details.map(d => d.message.replace(/"/g, '')).join(', ');
|
|
res.status(status.BadRequest).redirect("/questionnaire");
|
|
return;
|
|
}
|
|
|
|
users.updateOne(
|
|
{ _id: new ObjectId(req.session.userId) },
|
|
{
|
|
$set: {
|
|
financialData: true,
|
|
dob: value.dob,
|
|
education: value.education,
|
|
maritalStatus: value.maritalStatus,
|
|
income: value.income,
|
|
expenses: value.expenses,
|
|
assets: value.assets,
|
|
liabilities: value.liabilities,
|
|
}
|
|
}
|
|
).then((result) => {
|
|
if (result.matchedCount === 0) {
|
|
console.log(`User not found during questionnaire update: ${req.session.userId}`);
|
|
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.userId}`);
|
|
}
|
|
|
|
req.session.user.financialData = true;
|
|
req.session.errMessage = "";
|
|
|
|
req.session.save(err => {
|
|
if (err) {
|
|
res.status(status.InternalServerError).redirect("/plans");
|
|
}
|
|
res.status(status.Ok).redirect("/plans");
|
|
});
|
|
|
|
}).catch(err => {
|
|
console.error("Error updating questionnaire in database:", err);
|
|
req.session.errMessage = "An error occurred while saving your information. Please try again.";
|
|
res.status(status.InternalServerError).redirect("/questionnaire");
|
|
});
|
|
});
|
|
|
|
router.post("/updateAccount", async (req, res) => {
|
|
const accountSchema = joi.object({
|
|
email: joi.string().email(),
|
|
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),
|
|
});
|
|
|
|
const valid = accountSchema.validate(req.body);
|
|
|
|
if (valid.err) {
|
|
req.session.errMessage = "Invalid input",
|
|
res.status(status.BadRequest);
|
|
return res.redirect("/profile");
|
|
}
|
|
|
|
let update = {
|
|
name: req.body.name,
|
|
};
|
|
|
|
if ((req.body.password != "") && (req.body.password != req.body.repassword)) {
|
|
req.session.errMessage = "New passwords must match";
|
|
res.status(status.BadRequest);
|
|
return res.redirect("/profile");
|
|
} else if (req.body.password != "") {
|
|
let hashedPassword = await bcrypt.hashSync(req.body.password, salt);
|
|
update.password = hashedPassword;
|
|
}
|
|
|
|
users.updateOne(
|
|
{ _id: new ObjectId(req.session.userId) },
|
|
{ $set: update }
|
|
).then((result) => {
|
|
if (result.matchedCount === 0) {
|
|
console.log(`User not found during account update: ${req.session.userId}`);
|
|
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 account data unchanged (already up-to-date): ${req.session.userId}`);
|
|
}
|
|
|
|
req.session.errMessage = "";
|
|
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.";
|
|
return res.status(status.InternalServerError).redirect("/profile");
|
|
});
|
|
});
|
|
|
|
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 = {
|
|
userId: new ObjectId(req.session.userId),
|
|
...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);
|
|
newAsset.icon = type == "stock" ? "Stock" : type == "saving" ? "Coins" : newAsset.icon;
|
|
|
|
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.userId != req.session.userId) {
|
|
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;
|
|
delete update.userId;
|
|
|
|
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.error(`Asset not found: ${req.body.id}`);
|
|
req.session.errMessage = "Unable to delete asset. Please try again.";
|
|
return res.status(status.NotFound).redirect("/assets");
|
|
}
|
|
|
|
if (!result.acknowledged) {
|
|
console.error("Error deleting asset: ", err);
|
|
req.session.errMessage = "An error occurred while deleting an asset. 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 deleting asset: ", err);
|
|
req.session.errMessage = "An error occurred while deleting an asset. Please try again.";
|
|
return res.status(status.InternalServerError).redirect("/assets");
|
|
});
|
|
});
|
|
|
|
router.post("/deleteUser", (req, res) => {
|
|
// not as critical if results aren't as expected only if crashing
|
|
assets.deleteMany({ userId: new ObjectId(req.session.userId) }).catch((err) => {
|
|
console.error("Error deleting user assets: ", err);
|
|
req.session.errMessage = "An error occured while deleting your account. Please try again.";
|
|
return res.status(status.InternalServerError).redirect("/profile");
|
|
});
|
|
|
|
plans.deleteMany({ userId: new ObjectId(req.session.userId) }).catch((err) => {
|
|
console.error("Error deleting user assets: ", err);
|
|
req.session.errMessage = "An error occured while deleting your account. Please try again.";
|
|
return res.status(status.InternalServerError).redirect("/profile");
|
|
});
|
|
|
|
users.deleteOne(
|
|
{ _id: new ObjectId(req.session.userId) },
|
|
).then((result) => {
|
|
if (result.deletedCount === 0) {
|
|
console.error(`User not found: ${req.body.id}`);
|
|
req.session.errMessage = "Unabled to delete account. Please try again.";
|
|
return res.status(status.NotFound).redirect("/profile");
|
|
}
|
|
|
|
if (!result.acknowledged) {
|
|
console.error("Error deleting user: ", err);
|
|
req.session.errMessage = "An error occured while deleting your account. Please try again.";
|
|
return res.status(status.InternalServerError).redirect("/profile");
|
|
}
|
|
|
|
// Direct to logout to destroy session
|
|
req.session.destroy();
|
|
return res.status(status.Ok).redirect("/signup");
|
|
}).catch((err) => {
|
|
console.error("Error deleting user: ", err);
|
|
req.session.errMessage = "An error occured while deleting your account. Please try again.";
|
|
return res.status(status.InternalServerError).redirect("/profile");
|
|
});
|
|
});
|
|
|
|
router.get("/exRates/:lat/:lon", async (req, res) => {
|
|
if (!req.session.geoData.country) {
|
|
const response = await fetch(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${req.params.lat},${req.params.lon}&result_type=country&key=${process.env.GEOLOCATION_API}`);
|
|
const data = await response.json();
|
|
|
|
country = data.results[0].formatted_address;
|
|
let results = await getRates(country);
|
|
|
|
if (!results.exRates) {
|
|
req.session.geoData = {
|
|
message: "error"
|
|
}
|
|
} else {
|
|
req.session.geoData = {
|
|
country: results.abbreviation,
|
|
toCurrencyRates: results.exRates,
|
|
geoData: req.session.geoData
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
return res.status(status.Ok).send({ data: req.session.geoData });
|
|
})
|
|
|
|
return router;
|
|
};
|