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!!!