Building Dynamic Conversations: A Step-by-Step Guide to Implementing a Nested Comment System in Your Node.js Application
Published: 2024-01-09 06:31:43

[](url)![feature pic](https://images2.imgbox.com/ff/37/0yHRAJUA_o.png)

This blog post is about adding nested comment system in nodejs application. I have built a blogging web application <a href="https://www.pblog.online/" target = "_blank">PBlog </a> using Express.js and handlebar where user can create an account, login to the system and can write a post using markdown. Now, I am adding a nested comment system to this application. A nested comment system is a feature that allows users to comment on a post add replies to other comments and create a hierarchy of comments.

Now, let's create a file `comment.js` inside `Model` folder. In `comment.js` file we will define a schema for comments.

## Comment schema
``` javascript
const {Schema,model} = require("mongoose");

const commentSchema = new Schema({

postedBy:{
    type: Schema.Types.ObjectId,
    required:true,
    ref:"Users"
},
postId:{
type:Schema.Types.ObjectId,
required:true,
ref:"posts"
},
text:{
    type:String,
    required:true
},
parentComment:{
    type:Schema.Types.ObjectId,
    ref:"comments",
    default:null
},
commentedAt:{
    type:Date,
default: Date.now()
}, 
replies:[{
    type: Schema.Types.ObjectId,
    ref: "comments"
}]

});
commentSchema.pre("find", function( next){
    this.populate({path:"replies",
populate:{path:"postedBy"}

})
    next()
})

const Comment = new model("comments", commentSchema);
module.exports = Comment;
```

In the above code:
- **postedBy**: A reference to a user who posted that comment or reply
- **text**: A required field representing the `text` content of the comment.
- **postId**: A reference to a Post using its ObjectId.
- **parentComment**: A reference to another comment (if it's a reply), defaulting to null for top-level comments.
- **commentedAt**: The timestamp for when the comment was created, with a default value of the current date and time.
- **replies**: An array of ObjectId references to other comments, forming a nested structure for comment replies.

Following code will pre-Populate Replies and user information for each reply:
```
commentSchema.pre("find", function( next){
    this.populate({path:"replies",
populate:{path:"postedBy"}

})
    next()
})

```
It is a Mongoose middleware using `pre` which runs before the `find` operation. It populates the `replies` field with actual comment objects, and user information for each reply when querying the database.

Finally, we create and export the `Comment` model to make it available for use in other parts of the application
Now, let's create a route to handle the submission of comment for a specific post. I have created a new file `Comment.js` in `route` folder. This file will handle all routes related to comments.

## Route to post a comment
```javascript
const commentRoute = require("express").Router()
const flash = require("connect-flash");
const {body, validationResult} = require("express-validator")
const {islogin} = require("../utils/loginHandeler")
const Comment = require("../module/comment");

commentRoute.post("/:postId",islogin,[body("comment").trim().escape().notEmpty().withMessage("Comment field can not be empty!")], async(req,res)=>{
    const errors = validationResult(req)
    const {postId} = req.params;

    if(errors.isEmpty()){
const {comment} = req.body;

let commentObj = {
    postedBy:req.session.uid,
    postId,
    text:comment, 
}
try{
 await new Comment(commentObj).save()
}catch(er){
    console.log(er.message)
}
    }else{
        
        req.flash("error", `Failed to post a comment`)
    }
    
    res.redirect(`/${postId}#comment-field`)
})

```

Here, we've:
- Defined a route for handling comments
- imported `connect-flash` for flash messages, `express-validator` for input validation, `islogin` is a middleware for checking user is logged in or not. Because user need to log in to post a comment, and `Comment` model
- set up `Post` route to handle comment submissions
- used `express-validator` to validate the comment input, ensuring it's not empty and escaping any potentially harmful characters
- Saved comments in the database if there are no errors. If there is a validation error we've flashed an error message to be displayed
Finally, the user is redirected to the same post.

## Route to post a reply in a particular comment
```javascript

commentRoute.post("/:commentId/reply/:postId", islogin,[body("replyText").trim().escape().notEmpty()], async(req,res)=>{
const {commentId, postId} = req.params;
    const errors = validationResult(req)
const {replyText} = req.body;
if(errors.isEmpty()){
    const replyObj = {
        postedBy:req.session.uid,
        postId,
        text:replyText,
        parentComment:commentId
    }
    try{ 
        const newReply = await new Comment(replyObj).save()
        await Comment.findOneAndUpdate({_id:commentId, postId},{$push:{replies:newReply._id}})
    } catch(er){
console.log(er.message)
req.flash("error", `Failed to post a reply`)
    }
}

res.redirect(`/${postId}#${commentId}`)

})

```
In the above route, we have two parameters, `commentId` and `postId` which are needed to identify where a particular reply belongs to. Again, we have used `express-validator` for the input validation and `islogin` middleware to check if the user is logged in or not. If there are no errors, first we have saved reply in a database with `postId` and its `parentComment` id. Then, `replies` field of the parent comment is updated. Finally, the user is redirected to the same post page.

## Route to delete comment
The route to delete a comment will be similar to the route to post a reply. It will also have two parameters `commentId` and `postId` as we need to know which comment to delete.

```javascript

commentRoute.post("/:commentId/delete/:postId", islogin, async(req,res)=>{
    const {commentId, postId} = req.params;

try{
    const comment = await Comment.findOne({_id:commentId, postId}).lean()

    if(comment && comment.postedBy.toString() === req.session.uid){
        await Comment.deleteMany({_id:{$in:comment.replies}})
         await Comment.deleteOne({_id:commentId})
    }else{
        req.flash("error", `Comment failed to delete`)
    }

}catch(er){
    console.log(er.message)
   
}

res.redirect(`/${postId}#comment-field`)

})

module.exports = commentRoute;


```

In the above code, first, we tried to find a comment using `Comment.findOne()` method with the specified commentId and postId. If the comment is found and the user who posted it matches the currently logged-in user, it proceeds with the deletion process. Because only the user who posted that particular comment can delete that comment. We will show our UI according to this later on. If the logged-in user is the owner of that comment a delete button will be shown otherwise not. 
In our deletion process, it first deletes all replies associated with that particular comment
`  await Comment.deleteMany({_id:{$in:comment.replies}}) `
and then, it deletes the comment itself ` await Comment.deleteOne({_id:commentId})`. If the deletion is successful, it redirects the user back to the post. If the comment is not found, or the user is not the owner of the post, it flashes an error message and redirects the user back to the post page.
Lastly, we have exported the `CommentRoute` and we will import and use it in `app.js` file
```javascript
const commentRoute = require("./routes/comment");
app.use("/comment", commentRoute);

```

Now, let's look at what is happening in `/${postId}` route. This route is handled in `route.js` file.
```javascript
router.get("/:postId",  async(req,res)=>{
     const{postId}  = req.params;

    try{
  const  post = await Posts.findById({_id: postId}).populate({path:"uid",select:"-password"}).lean();
const comments = await Comment.find({postId, parentComment:null}).sort({_id:1}).populate({path:"replies"}).populate({path:"postedBy", select:"-password"}).lean();
if(post){
      res.render("singlePost",{         
  post,
  comments,
 userStatus
   })
  }

}catch(er){
    console.log(er)
}

})

```
This route will send a single post to the user, first, it find a particular post with `postId` and then, It will find the top-level comment associated with that post and populate `replies` and `postedBy` a user information who posted that particular comment. `userStatus` will have currently logged-in user information if the user is logged-in otherwise it will return `userStatus.login` to `false`

## Comment input User Interface
We have set up all routes related to comments. Now, it's time to work on frontend. First up all, we will create UI to post a comment. I've created  `-commentInput.handlebars` inside `partial` folder of `view` directory. Following code goes inside this file:
```javascript
   {{> message}}
<div class = "comment-wrapper" id="comment-field">
 
   <div class="user_pic">
        <img alt="user-pic" src="{{userStatus.profileURL}}" />
      </div>
  <div class = "comment-input" >
  <form action="/comment/{{post._id}}" method="post">
  
    <textarea rows = "5" placeholder="Enter your comment"  cols="40" name="comment"></textarea>
    <br>
    <button class="btn" type = "submit"> Submit </button>
    </form>
  
  </div>
</div>


```
Here, `{{> message}}`  is a partial template that will show an error message if there is any, and I've used it in `singlePost.handlebars` as following:
```javascript
  
{{#if userStatus.login}}
  {{> -commentInput }}
  {{/if}}
  
{{#if comments}}
{{> displayComment }}
{{else}}
<h5>No comments</h5>
{{/if}}

```
The comment input box will only be seen if the user is logged-in, and if the particular post has a comment, the comment will display. Now, we will see `{{> displayComment}}` partial template in detail.

![comment-input-UI](https://images2.imgbox.com/f0/34/kJFIuDt7_o.png)

## Rendering comment
Let's create another file `displayComment.handlebars` in `partial` folder in `view` directory. `displayComment.handlebars` will do following:
- Display comment and its reply recursively
- If user is not logged-in only comment will be seen
- If user is logged-in a `Reply` button should be there for user to put the reply
- If user is logged-in and if there is any comment won by that user a `Reply` button as well as a `Delete` button should be there so that user would be able to delete that comment

Following lines of code will in `displayComment.handlebars` file:

```
<div class="comment">
{{#each comments}}

<div class = "single-cmt-card" id="{{this._id}}">
  <div class = "card-header">
   <div class="user_pic">
       <a href="/user?id={{this.postedBy._id}}">
       
        <img alt="user-pic" src="{{this.postedBy.profileURL}}" />
       </a>
        
      </div>
    <div class = "user-name">
      <b>{{this.postedBy.name}}</b>
            <span class="post_date"> {{formatDate this.commentedAt}}</span>
    
  </div>
  </div>
  <div class="card-body">
 {{this.text}}
 
    </div>



<div class = "action-btns"> 

{{#if (showBtns "reply")}}
<button class="reply-btn"> Reply 
  </button>
{{/if}}

  {{#if (showBtns "delete" this.postedBy._id)}}
  <form  action="/comment/{{this._id}}/delete/{{this.postId}}" method="post" >
  <button type="submit" class= "delete-btn"> Delete </button>
  </form>
  {{/if}}

</div>

{{#if (showBtns "reply")}}
 <div class = "reply-cmt">
  <form action="/comment/{{this._id}}/reply/{{this.postId}}" method="post">
      <textarea required class="reply-textarea" name="replyText" rows="3"  placeholder = 'Enter your reply'></textarea>
      <div class = "action-btns">
        <button type="submit"> Submit </button>
        <button type="button" class = "cancel-btn"> Cancel </button>
    
      </div>

  </form>
    </div>

{{/if}}
</div>



 {{#if this.replies}}
<div class="reply" style="margin-left: 5px; border-left: solid 1px var(--primary-text-color);">
    {{> displayComment comments = this.replies}}
</div>
{{/if}} 

{{/each}}

</div>
```
The above code will display comments and their replies recursively. `formatDate` and `showBtns` are <a href = "https://github.com/express-handlebars/express-handlebars" target = "_blank">helper functions </a>  to display the comment's relative time, and conditionally display buttons based on user permissions. `Reply` button triggers the display of a reply form for the comment which includes submit and cancel buttons to send or discard the reply. I've used a vanilla javascript to dynamically toggle the display of reply forms for individual comments. `Delete` button submits a form to delete the comment.

![display-comment](https://images2.imgbox.com/1d/99/73X9MBeE_o.png)

![display-comment](https://images2.imgbox.com/74/1c/PgW22WT0_o.png)

## Helper functions
Now, let's look at these two helper functions in details. I have created `helperFunctions.js` file inside `util` folder and following code will go in that file:
```javascript

const {userStatus} = require("./userStatusChecker");
const moment = require("moment");

const formatDate = (date) =>{
   return moment(date).fromNow()
}

const showBtns = (buttonType, userId) =>{
   const {uid, login} = userStatus
switch(buttonType){
   case "reply":
    return login
    case "delete":
        return login && uid === userId.toString();
        default:
        return false
}
}

module.exports = {formatDate, showBtns}
```
In above code, `formatDate(date)` formats a date into a more human-readable format, specifically using relative time expressions like "a few seconds ago", "5 minutes ago" etc. I've used `moment` library for that and used `moment(date).fromNow()` to generate a relative time.
`showBtns(buttonType, userId)` conditionally determines wheather to display buttons based on user login status and user ID. `userStatus` will retrieve user status from `userStatusChecker` module. For `reply` button type will return `true` if user is logged-in. For `delete` button type will return `true` if user is logged-in and current user id is matched with comment posted user id otherwise return `false`.
Finally, we need to register these helper functions in our `app.js` file
```javascript
const {formatDate, showBtns} = require("./utils/helperFunctions");

app.engine("handlebars", handlebars({
  helpers:{
    formatDate,
 showBtns
  }
})); 
```
In this way, I have added nested comment system in my app.
Finally, I would like to thank you for reading this post.