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...
Here's the ERD we'll use as an example:
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.
Because comments are being embedded within the book documents, there is no Comment model, just a schema.
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.
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.
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 |
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) |
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`);
}
}
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');
}
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 });
}
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');
}
}
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}`);
}
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
});
}
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}`);
}
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}`);
}
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}`);
}
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!