Embedded Mongoose Relationship
In a previous lesson we had created a relationship using ObjectIDs and References. In this lesson we use an Embedded relationship. This is done by embedding a Schema with another on.
In this case we will have users who can have goals. Instead of having two models, goals and users. There will just be Users who have goals embedded within their schema.
The Pros
- Easier to Work with (You don't have to constantly update two objects)
The Cons
- There is a size limit for a single document in Mongo and embedded relationships can result in exceeding the limit over time.
Setup
- generate a project using the EJS/Express/Mongo Supreme template
npx degit AlexMercedCoder/ejs-express-supreme#main usergoals
- cd into the
usergoals
folder - install dependencies
npm install
- rename template.env into .env and add your mongodb url to the MONGODB_URL variable
Creating our User Model with Goals!
Since we are doing our Goals as an embedded relationship to Users, we will create the User and Goal schema within one file.
models/User.js
// Import Schema and Model
const { Schema, model } = require("../db/connection.js")
// The Goal Schema
const Goal = new Schema({
text: String,
})
// The User Schema
const UserSchema = new Schema(
{
username: { type: String, unique: true, required: true },
password: { type: String, required: true },
// The goals property defined as an array of objects that match the Goal schema
goals: [Goal],
},
{ timestamps: true }
)
// The User Model
const User = model("User", UserSchema)
// Export the User Model
module.exports = User
That's it! Our model is created!
User Signup Page
Well, before we can add any goals we need a user but we need the following routes...
- get route to render a signup form
- post route for submitting the signup form
- get route to render a login form
- post route for submitting the login form
- get route for logging out
We could but these in their own router but to keep things simple we will write all our routes in the HomeRouter and write our Handler/Controller functions within the routes.
Let's scope out all our routes with a placeholder response.
routes/home.js
// AUTH RELATED ROUTES
//SIGNUP ROUTES
router.get("/auth/signup", (req, res) => {
res.send("signup get")
})
router.post("/auth/signup", (req, res) => {
res.send("signup post")
})
//Login ROUTES
router.get("/auth/login", (req, res) => {
res.send("login get")
})
router.post("/auth/login", (req, res) => {
res.send("login post")
})
//Logout Route
router.get("/auth/logout", (req, res) => {
res.send("logout")
})
Let's test our get routes are working by checking the following in the browser for our placeholder text responses.
- localhost:3000/auth/signup
- localhost:3000/auth/login
- localhost:3000/auth/logout
Signup
- Let's update our signup get route to render a view for the signup page.
router.get("/auth/signup", (req, res) => {
res.render("auth/signup")
})
- now let's create views/auth/signup.ejs
note: make sure to update the include paths as shown below. Remember, include file paths like requires are relative.
<!DOCTYPE html>
<html lang="en">
<!-- THE HEAD TAG PARTIAL -->
<%- include("../partials/head") %>
<body>
<!-- THE HEADER TAG PARTIAL -->
<%- include("../partials/header") %>
<main>
<h1>User Signup</h1>
<form action="/auth/signup" method="post">
<input type="text" placeholder="username" name="username" />
<input type="password" placeholder="password" name="password" />
<input type="submit" value="sign up" />
</form>
</main>
<!-- THE HEAD TAG PARTIAL -->
<%- include("../partials/footer") %>
</body>
</html>
- make sure to install bcryptjs
npm install bcrypt
(a precompiled version of bcrypt) - let's update our signup post route to create our new user and redirect them to the login page.
routes/home.js
import bcrypt and the User model
///////////////////////////////
// Import Router
////////////////////////////////
const router = require("express").Router()
const bcrypt = require("bcryptjs")
const User = require("../models/User")
Update signup post route
router.post("/auth/signup", async (req, res) => {
try {
// generate salt for hashing
const salt = await bcrypt.genSalt(10)
// hash the password
req.body.password = await bcrypt.hash(req.body.password, salt)
// Create the User
await User.create(req.body)
// Redirect to login page
res.redirect("/auth/login")
} catch (error) {
res.json(error)
}
})
- Now test it out, when you submit you should see the dummy text from your login route. Double check on mongodb.com that the data got added.
Login
So now we need to create our login form page and post route to receive the form data and verify our login (check if user exists and that password matches).
- Update our login get route so we can render our login page.
routes/home.js
//Login ROUTES
router.get("/auth/login", (req, res) => {
res.render("auth/login")
})
- create our view in views/auth/login.ejs
<!DOCTYPE html>
<html lang="en">
<!-- THE HEAD TAG PARTIAL -->
<%- include("../partials/head") %>
<body>
<!-- THE HEADER TAG PARTIAL -->
<%- include("../partials/header") %>
<main>
<h1>User Login</h1>
<form action="/auth/login" method="post">
<input type="text" placeholder="username" name="username" />
<input type="password" placeholder="password" name="password" />
<input type="submit" value="Login" />
</form>
</main>
<!-- THE HEAD TAG PARTIAL -->
<%- include("../partials/footer") %>
</body>
</html>
- test out the form works and show your dummy text from your post
-
now let's update our post route for login which should do the following
- check if the user exists
- check if password matches
- create user session property (we'll use this to know if a user is logged in)
- redirect user to not yet existing "/goals" page!
routes/home.js
router.post("/auth/login", async (req, res) => {
try {
//check if the user exists (make sure to use findOne not find)
const user = await User.findOne({ username: req.body.username })
if (user) {
// check if password matches
const result = await bcrypt.compare(req.body.password, user.password)
if (result) {
// create user session property
req.session.userId = user._id
//redirect to /goals
res.redirect("/goals")
} else {
// send error is password doesn't match
res.json({ error: "passwords don't match" })
}
} else {
// send error if user doesn't exist
res.json({ error: "User does not exist" })
}
} catch (error) {
res.json(error)
}
})
Create the Logout Route
The logout route will just remove the user property so the auth middleware we will make will block the logged out user.
//Logout Route
router.get("/auth/logout", (req, res) => {
// remove the userId property from the session
req.session.userId = null
// redirect back to the main page
res.redirect("/")
})
The Goals Page
Now we got authentication setup, let's begin wiring things up to create our goals page!
- first let's add login and signup pages on on the home page.
views/home.ejs
<!DOCTYPE html>
<html lang="en">
<!-- THE HEAD TAG PARTIAL -->
<%- include("partials/head") %>
<body>
<!-- THE HEADER TAG PARTIAL -->
<%- include("partials/header") %>
<main>
<h1 class="title">Goals App</h1>
<h2 class="subtitle">Where we track goals</h2>
<a href="/auth/signup"><button class="button">SIGNUP</button></a>
<a href="/auth/login"><button class="button">LOGIN</button></a>
</main>
<!-- THE HEAD TAG PARTIAL -->
<%- include("partials/footer") %>
</body>
</html>
- next let's create our auth middleware function. We'll use it as a route specific middleware to make sure routes that should only be accessed by logged in users are protected. We'll write it in our routes file along with our goals get route, when done your routes/home.js should look like this.
///////////////////////////////
// Import Router
////////////////////////////////
const router = require("express").Router()
const bcrypt = require("bcryptjs")
const User = require("../models/User")
///////////////////////////////
// Custom Middleware Functions
////////////////////////////////
// Middleware to check if userId is in sessions and create req.user
const addUserToRequest = async (req, res, next) => {
if (req.session.userId) {
req.user = await User.findById(req.session.userId)
next()
} else {
next()
}
}
// Auth Middleware Function to check if user authorized for route
const isAuthorized = (req, res, next) => {
// check if user session property exists, if not redirect back to login page
if (req.user) {
//if user exists, wave them by to go to route handler
next()
} else {
//redirect the not logged in user
res.redirect("/auth/login")
}
}
///////////////////////////////
// Router Specific Middleware
////////////////////////////////
router.use(addUserToRequest)
///////////////////////////////
// Router Routes
////////////////////////////////
router.get("/", (req, res) => {
res.render("home")
})
// AUTH RELATED ROUTES
//SIGNUP ROUTES
router.get("/auth/signup", (req, res) => {
res.render("auth/signup")
})
router.post("/auth/signup", async (req, res) => {
try {
// generate salt for hashing
const salt = await bcrypt.genSalt(10)
// hash the password
req.body.password = await bcrypt.hash(req.body.password, salt)
// Create the User
await User.create(req.body)
// Redirect to login page
res.redirect("/auth/login")
} catch (error) {
res.json(error)
}
})
//Login ROUTES
router.get("/auth/login", (req, res) => {
res.render("auth/login")
})
router.post("/auth/login", async (req, res) => {
try {
//check if the user exists (make sure to use findOne not find)
const user = await User.findOne({ username: req.body.username })
if (user) {
// check if password matches
const result = await bcrypt.compare(req.body.password, user.password)
if (result) {
// create user session property
req.session.userId = user._id
//redirect to /goals
res.redirect("/goals")
} else {
// send error is password doesn't match
res.json({ error: "passwords don't match" })
}
} else {
// send error if user doesn't exist
res.json({ error: "User does not exist" })
}
} catch (error) {
res.json(error)
}
})
//Logout Route
router.get("/auth/logout", (req, res) => {
// remove the user property from the session
req.session.userId = null
// redirect back to the main page
res.redirect("/")
})
// Goals Index Route render view (we will include new form on index page) (protected by auth middleware)
router.get("/goals", isAuthorized, async (req, res) => {
// get updated user
const user = await User.findOne({ username: req.user.username })
// render template passing it list of goals
res.render("goals", {
goals: user.goals,
})
})
// Goals create route when form submitted
router.post("/goals", isAuthorized, async (req, res) => {
// fetch up to date user
const user = await User.findOne({ username: req.user.username })
// push new goal and save
user.goals.push(req.body)
await user.save()
// redirect back to goals index
res.redirect("/goals")
})
///////////////////////////////
// Export Router
////////////////////////////////
module.exports = router
- create our views/goals.ejs file, we'll add our creation form right on the page so we don't have create a separate new page.
<!DOCTYPE html>
<html lang="en">
<!-- THE HEAD TAG PARTIAL -->
<%- include("partials/head") %>
<body>
<!-- THE HEADER TAG PARTIAL -->
<%- include("partials/header") %>
<main>
<!-- *********DISPLAY OUR GOALS********* -->
<h1 class="title">My Goals</h1>
<a href="/auth/logout"><button class="button">Logout</button></a>
<% for (goal of goals) { %>
<h2 class="subtitle is-3"><%= goal.text %></h2>
<% } %>
<!-- **********NEW GOALS FORM**************** -->
<form action="/goals" method="post">
<input type="text" placeholder="new goal" name="text" />
<input type="submit" value="add goal" />
</form>
</main>
<!-- THE HEAD TAG PARTIAL -->
<%- include("partials/footer") %>
</body>
</html>
Testing!
It works!!! Try logging in as multiple users!!!