Skip to content

Instantly share code, notes, and snippets.

@jim-clark
Last active April 15, 2024 01:03
Show Gist options
  • Save jim-clark/a714016bab26fad52106f6b2490e3eb7 to your computer and use it in GitHub Desktop.
Save jim-clark/a714016bab26fad52106f6b2490e3eb7 to your computer and use it in GitHub Desktop.

Guide to User-Centric CRUD using Express & Mongoose

Intro

Typically, logged in users interact with an application that results in data being created, updated and deleted.

In almost every case, the application's code will need to ensure that the logged in user can only update/delete data created by them, not the data of other users.

To ensure this is the case, newly created data will need to reference which user created it regardless of whether that data is being referenced or embedded.

This guide will show an example of how to handle the above scenario...

Example Data Model

Here's the ERD we'll use as an example:

User Has Two Different Relationships with Books

Note that in this app, a user "recommends" a book to other users by creating it in the database. This one-to-many relationship is modeled with a userRecommending property on the Book model that references the _id of the user that created each particular book.

In addition, users can add books to their reading list. This many-to-many relationship is modeled with a usersReading property which references an array of user documents' _id values.

Comments

Because comments are being embedded within the book documents, there is no Comment model, just a schema.

Restricting Updating and/or Deleting of Comments Functionality

Each comment needs to know the user that submitted it. Not just for display purposes, but to restrict the ability to update and/or delete a comment to that of the user that submitted it. The userId property in the comment schema holds the _id of the user that submitted the comment and can therefore be compared to the logged in user's _id to render the additional UI for updating/deleting.

Copying Data For Better Efficiency

Since displaying the name of the user commenting on a book makes sense, note that, in addition to the userId property, the comment schema also has a userName property for holding the user's name.

Copying over the user's name from req.user in the comment create action will avoid having to populate comments every time they are accessed. This provides much better efficiency.

Example Routing

Books

HTTP
Method
URL
Endpoint
Controller
Action
Purpose
GET /books booksCtrl.index View all the books submitted by the logged in user
GET /books/all booksCtrl.allBooks View all the books regardless of who submitted (use querystring params to perform filtering)
GET /books/:id booksCtrl.show View the details of any book
GET /books/new booksCtrl.new View a form for submitting a book (be sure to define this route before the show route)
POST /books booksCtrl.create Handle the new book form being submitted
GET /books/:id/edit booksCtrl.edit View a form for editing a book (restrict to user who submitted the book)
PUT /books/:id booksCtrl.update Handle the edit book form being submitted (restrict to user who submitted the book)
DELETE /books/:id booksCtrl.delete Delete a book (restrict to user who submitted the book)
POST /books/:id booksCtrl.addReading Add the logged in user's _id to a book's userReading array

Comments

HTTP
Method
URL
Endpoint
Controller
Action
Purpose
n/a n/a index action View all the comments for a book - no route needed since comments are embedded and displayed with their book
n/a n/a show action Viewing a single comment does not make sense
n/a n/a new action The form to add a new comment should be displayed on the book's show view
POST /books/:id/comments commentsCtrl.create Handle the new comment form being submitted
GET /comments/:id/edit commentsCtrl.edit View a form for editing a comment (restrict to user who submitted the comment)
PUT /comments/:id commentsCtrl.update Handle the edit comment form being submitted (restrict to user who submitted the comment)
DELETE /comments/:id commentsCtrl.delete Delete a comment (restrict to user who submitted the comment)

Example Controller Code

Creating a book

async function create(req, res) {
  const book = new Book(req.body);
  // Assign the logged in user's id
  book.userRecommending = req.user._id;
  try {
    await book.save();
    // Probably want to go to newly added book's show view
    res.redirect(`/books/${book._id}`);
  } catch (e) {
    console.log(e.message);
    // Probably want to go back to new
    res.redirect(`/books/new`);
  }
}

Deleting a book

async function deleteBook(req, res) {
  await Book.findOneAndDelete(
    // Query object that ensures the book was created by the logged in user
    {_id: req.params.id, userRecommending: req.user._id}
  );
  // Deleted book, so must redirect to index
  res.redirect('/books');
}

Edit a book

async function edit(req, res) {
  const book = await Book.findOne({_id: req.params.id, userRecommending: req.user._id});
  if (!book) return res.redirect('/books');
  res.render('books/edit', { book });
}

Update a book

async function update(req, res) {
  try {
    const updatedBook = await Book.findOneAndUpdate(
      {_id: req.params.id, userRecommending: req.user._id},
      // update object with updated properties
      req.body,
      // options object {new: true} returns updated doc
      {new: true}
    );
    return res.redirect(`/books/${updatedBook._id}`);
  } catch (e) {
    console.log(e.message);
    return res.redirect('/books');
  }
}

Adding a book to a user's reading list

async function addReading(req, res) {
  const book = await Book.findById(req.params.id);
  // Ensure that user is not already in usersReading
  // See "Finding a Subdocument" in https://mongoosejs.com/docs/subdocs.html
  if (book.usersReading.id(req.user._id)) return res.redirect('/books');
  book.usersReading.push(req.user._id);
  await book.save();
  res.redirect(`/books/${book._id}`);
}

View all books or based upon a name search

async function allBooks(req, res) {
  // Make the query object to use with Book.find based upon
  // if the user has submitted via a search form for a book name
  let bookQuery = req.query.name ? {name: new RegExp(req.query.name, 'i')} : {};
  const books = await Book.find(bookQuery);
  // Why not reuse the books/index template?
  res.render('books/index', {
    books,
    nameSearch: req.query.name  // use to set content of search form
  });
}

Add a comment

A form used to create a comment would look something like:

<!-- Using the RESTful route to send the book's id to the server -->
<form action="/books/<%= book._id %>/comments" method="POST">
  <!-- Be sure name attributes of inputs match the model properties -->
  <input name="text">
  <button type="submit">ADD COMMENT</button>
</form>

In the comment controller's create action, we'll need to first find the book to add the comment to:

async function create(req, res) {
  try {
  const book = await Book.findById(req.params.id);
    // Update req.body to contain user info
    req.body.userId = req.user._id;
    req.body.userName = req.user.name;
    // Add the comment
    book.comments.push(req.body);
    await book.save();
  } catch (e) {
    console.log(e.message);
  }
  res.redirect(`/books/${book._id}`);
}

Update a comment

A form used to edit a data resource needs to use a query string to inform method-override middleware to change the post to a PUT request:

<form action="/comments/<%= comment._id %>?_method=PUT" method="POST">
  <!-- Value attribute is being set to the comment's current text -->
  <input name="text" value="<%= comment.text %>">
  <button type="submit">UPDATE COMMENT</button>
</form>

However, note that the edit form above needs the comment subdoc to be passed from the edit controller action in order to properly emit the form's action path and pre-fill in the input element(s). Here's what that edit action code might look like:

async function edit(req, res) {
  // Note the cool "dot" syntax to query on the property of a subdoc
  const book = await Book.findOne({'comments._id': req.params.id});
  // Find the comment subdoc using the id method on Mongoose arrays
  // https://mongoosejs.com/docs/subdocs.html
  const comment = book.comments.id(req.params.id);
  // Render the comments/edit.ejs template, passing to it the comment
  res.render('comments/edit', { comment });
}

When the edit comment form is submitted, the update action will need to find the book that the comment is embedded within based upon the _id of the comment being sent as a route parameter:

async function update(req, res) {
  // Note the cool "dot" syntax to query on the property of a subdoc
  const book = await Book.findOne({'comments._id': req.params.id});
  // Find the comment subdoc using the id method on Mongoose arrays
  // https://mongoosejs.com/docs/subdocs.html
  const commentSubdoc = book.comments.id(req.params.id);
  // Ensure that the comment was created by the logged in user
  if (!commentSubdoc.userId.equals(req.user._id)) return res.redirect(`/books/${book._id}`);
  // Update the text of the comment
  commentSubdoc.text = req.body.text;
  try {
    await book.save();
  } catch (e) {
    console.log(e.message);
  }
  // Redirect back to the book's show view
  res.redirect(`/books/${book._id}`);
}

Delete a comment

A form used to delete a data resource needs to use a query string to inform method-override middleware to change the post to a DELETE request. Of course, we only want to show the delete form if the comment was created by the logged in user.

Also, note that the proper RESTful route passes the _id of the comment, not the book that it's embedded within:

<% if (user?._id.equals(comment.user)) { %>
  <form action="/comments/<%= comment._id %>?_method=DELETE" method="POST">
    <button type="submit">DELETE COMMENT</button>
  </form>
<% } %>

However, you'll only want to render the above form if the comment was created by the logged in user - you don't want users deleting each other's comments! Here's how you can conditionally render the delete comment form for only the comments created by the logged in user:

<% book.comments.forEach(function(comment) { %>
  <div class="comment">
    <%= comment.text %><br>
    <% if (user && comment.userId.equals(user._id)) { %>
      <form action="/comments/<%= comment._id %>?_method=DELETE" method="POST">
        <button type="submit">X</button>
      </form>
    <% } %>
  </div>
<% }) %>

Note that using a simple "X" as the button text, along with some styling provides for a decent UI.

When the delete comment form is submitted, just like with the update action above, the delete action will need to find the book that the comment is embedded within based upon the _id of the comment being sent as a route parameter and also ensuring that the logged in user was the creator of the comment:

async function deleteComment(req, res) {
  // Note the cool "dot" syntax to query on the property of a subdoc
  const book = await Book.findOne({'comments._id': req.params.id, 'comments.userId': req.user._id});
  if (!book) return res.redirect(`/books/${book._id}`);
  // Remove the subdoc (https://mongoosejs.com/docs/subdocs.html)
  book.comments.remove(req.params.id);
  // Save the updated book
  await book.save();
  // Redirect back to the book's show view
  res.redirect(`/books/${book._id}`);
}

Avoiding Having to Pass user Every render

How about a small custom middleware that relieves us from having to pass user: req.user every time a view is rendered!!!!

Just add the following in server.js BELOW the two app.use(passport...) middleware:

// Add this middleware BELOW passport middleware
app.use(function (req, res, next) {
  res.locals.user = req.user;
  next();
});

The res.locals is an object whose properties are available inside of any view being rendered!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment