Building Dynamic Conversations: A Step-by-Step Guide to Implementing a Nested Comment System in Your Node.js Application

feature pic

This blog post is about adding nested comment system in nodejs application. I have built a blogging web application PBlog 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

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

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


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.


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

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.

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:

   {{> 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:

  
{{#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

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 helper functions 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

display-comment

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:


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

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.

Comments

Ny ky Duyen
Thank you for writing this blog post. I learnt a lot from it.