How to Build a Blog Comments System Using Go, Postgres, and Vanilla Javascript

• Jason Ladd

TLDR;

To build a comments system for a blog, you need to:

  • 1. Create a form that allows users to submit comments
  • 2. On the back end, create some logic that receives those comments, sanitizes the input, and saves them into a database
  • 3. The database schema needs to be decided so that comments can store the neccessary information like the comment body text, the author of the comment, the date it was submitted, the id of the post it belongs to, and if it's a reply to any other comment, what the id of that comment is
  • 4. The back end also needs some logic to retrieve the comments from the database and display them on under the blog post in a hierarchical ordering that shows replies to other comments nested under them
  • 5. Since spam and bots are prevalent online, there should also be a way for the admin of the blog to review each comment and decide if it's ok to be posted before it actually is visible to the public.

Designing and Building the Comments System

So I decided I'd like to add the ability for people to leave comments on my blog posts. There are of course a lot of different ways you could do this, but I'd like to approach it in a way that's highly scalable so that I could use this logic on something much bigger than my personal blog in the future. For example, one really simple way of doing it could be to add a field to the 'blog posts' table in the db that stores comments as JSON. Then, whenever new comments are added, I could just retrieve the JSON, add the new comment into the JSON array, then save it back to the post. Honestly, this works but a much more robust way to do this is to save comments in their own table, and on each comment save the id of the post it belongs to as a foreign key. This will allow us to have much more granular control over querying comments and keep all of our additions and deletions atomic. I'll explain more about that concept later, but the key idea is that, if there's any error in adding or deleting a single comment, we don't want to risk losing all the other comments, which could happen if they're all stored in one JSON array. There's actually quite a bit to building out a feature like this but the main chunks that have to be decided are:

  • 1. UI Design and User Experience
  • 2. Database Schema Design
  • 3. Backend Logic

Before we actually write any code, let's think about what type of user experience we want for this comments system, since that will determine what code actually needs to be written. Users should be able to:

  • 1. Post comments under a blog article, either directly under the article or as a reply to another comment
  • 2. View comments for a post in order of most recent to oldest
  • 3. View the number of comments on a post (without needing to query the comments table)
  • 4. View comments that are replys to other comments as nested under the comment they're a reply to
  • 5. Review comments and decide if they should be publicly visible (the site admin)
  • 6. Delete and restore comments (the site admin)

The Database Schema

At first, I was thinking that to render the nested comments, we would need to be able to render a comment template inside of another comment template, so basically a recursive template. But then, I had another idea that might actually be cleaner. The main point of doing the nested comments is the visual communication of the comment tree heirarchy. But taking a step back, I realized that this can be communicated simply by indenting the comments based on how many levels deep they're nested. This means, we can build a tree structure with each comment in chronological order under their parent comment, then flatten the tree, retaining that order with each comment storing a depth level indicating how deep it should be nested. Then to display them, we only need to render that array of comments and indent each one based on how many levels deep it should be nested. No recursive templating required. But before we get into the logic of the tree building, flattening, let's create the comments table and schema.

First, let's create a migration file (I'm using golang-migrate) that will update the db to add the comments table and a field on the blog posts table to store the amount of comments it has:


migrate create -ext sql -dir db/migrations -seq add_comments_table

Then, inside the migrate up file that was just created, let's add the SQL to create the table and indicies:


CREATE TABLE IF NOT EXISTS comments(
    id              SERIAL PRIMARY KEY,
    post_id         integer NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    parent_id       integer REFERENCES comments(id) ON DELETE CASCADE,
    user_id         integer REFERENCES users(id),
    author          VARCHAR(50) NOT NULL,
    body            TEXT NOT NULL,
    is_approved     BOOLEAN DEFAULT FALSE,
    created_date    TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    deleted_date    TIMESTAMP WITH TIME ZONE
);

ALTER TABLE posts ADD COLUMN "comments_count" INTEGER DEFAULT 0;

CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments(post_id);
CREATE INDEX IF NOT EXISTS idx_comments_created_date ON comments(created_date);
CREATE INDEX IF NOT EXISTS idx_comments_parent_id ON comments(parent_id);

Even though we're adding the ability to soft delete, I'm still going to put in an is_approved boolean field so that I can moderate the comments even before they ever show up. The boolean should be enough for now, but there might some cases where you'd want an enum so you could represent different statuses, for example, approved, denied, pending, etc. But here, I think a boolean that defaults to false is good enough. Our public display logic will only retreive comments where is_approved is true.

Note: I'm also adding an integer field on the posts table to keep up with the amount of comments on a post. This isn't really crucial, but will be a nice touch and, If we want to do lazy-loading, this will let us know if whether we should attempt to do the load or not. We'll have to add some logic to increment it every time a comment is approved for a post, and decrement it every time one is deleted from it.

Then, inside the migrate down file, let's add the SQL to revert the db back to it's previous schema:


DROP TABLE IF EXISTS comments;
ALTER TABLE posts DROP COLUMN comments_count;

Then, finally we can run the migrate up command to actually create the table:


migrate -path db/migrations -database "postgres://user:password@localhost/dbname?sslmode=disable" up
Note: running the migration manually is fine like this for local development, but when we go to deploy, we're going to run into issues if our db is not publicly accessible on some port, say 5432 which in a deployed environment, it SHOULDN'T be. In that case, if we're running the app inside a Docker container (which I am), we can run the migration command inside of the docker container, but honestly, this is a bit of a pain too. So to make things easier, I'm going to add some code into the main function that just runs migrations on startup. I still think you should run them manually during development because you can be sure they work properly first, but when deploying, this is much easier. Using golang-migrate, we can do this:

func runMigrations() error {
	dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
		os.Getenv("POSTGRES_USER"),
		os.Getenv("POSTGRES_PASSWORD"),
		os.Getenv("POSTGRES_HOST"),
		os.Getenv("POSTGRES_PORT"),
		os.Getenv("POSTGRES_DB"),
	)

	m, err := migrate.New("file:///app/db/migrations", dsn)
	if err != nil {
		return fmt.Errorf("failed to create migrator: %w", err)
	}
	defer m.Close()

	if err := m.Up(); err != nil && err != migrate.ErrNoChange {
		return fmt.Errorf("failed to run migrations: %w", err)
	}

	log.Println("Database migrations applied successfully")
	return nil
}

func main() {
	if err := initDB(); err != nil {
		log.Fatalf("Failed to initialize database: %v", err)
	}

	if err := runMigrations(); err != nil {
		log.Fatalf("Failed to run database migrations: %v", err)
	}
...
}

Now, when we run the app, it will apply any migrations that haven't been run yet, so deploying is much easier!

The Go Backend Logic

Now that we've got the database set up to store our comments, we need to create the back end logic that will be responsible for saving, retreiving, and deleiting comments. First, let's create a Comment Struct that we can use to represent a comment we get back from the database:


type Comment struct {
	ID         int32          `json:"id"`
	IsApproved bool           `json:"is_approved"`
	UserId     int32          `json:"user_id"`
	User       *User          `json:"user"`
	Author     sql.NullString `json:"author"`
	PostId     int32          `json:"post_id"`
	PostTitle  sql.NullString `json:"post_title"`
	ParentId   sql.NullInt32  `json:"parent_id"`
	Body       string         `json:"body"`
	Depth      int32          `json:"depth"`
	Children   []*Comment
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  string    `json:"updated_at"`
}

Sinc we're updating our database to keep up with the amount of comments on a post, we'll need to add a CommentsCount field onto our BlogPost struct too:


type BlogPost struct {
	ID             int32          `json:"id"`
	UserId         int32          `json:"user_id"`
	User           *User          `json:"user"`
	Title          string         `json:"title"`
	Slug           string         `json:"slug"`
	Content        string         `json:"content"`
	ContentPreview sql.NullString `json:"content_preview"`
	ImgLoPath      sql.NullString `json:"img_lo_path"`
	ImgHiPath      sql.NullString `json:"img_hi_path"`
	CommentsCount  int32          `json:"comments_count"`
	CreatedAt      time.Time      `json:"created_at"`
	UpdatedAt      string         `json:"updated_at"`
}

Inserting Comments into The Database

This query is pretty simple, all we have to do is insert the comment into the table:


func insertComment(dbPool *pgxpool.Pool, c Comment) error {
	_, err := dbPool.Exec(context.Background(), `
		INSERT INTO comments (user_id, post_id, parent_id, author, body) VALUES ($1, $2, $3, $4, $5)`,
		c.UserId, c.PostId, c.ParentId, c.Author, c.Body)

	if err != nil {
		log.Fatal(err)
	}

	return err
}
Note: We're using pgxpool's 'Exec' method instead of 'Query' here because Query saves the results of the operation into 'rows' in memory. If we did that, it would work but if we forget to close those rows, we'd end up with a memory leak that would crash the app after a few inserts! So if you're doing mutations that don't need to use any returned data, it's easiest to just use 'Exec'.

Deleting Comments from The Database

We'll be implementing soft-deletes here, just in case we ever delete something by accident and want to restore it. The other good side effect is that we can render a 'deleted comment' replacement for the comment if we want to maintain the comment structure even after a deletion. This means we could delete a comment but keep it's children in place even after it's 'gone'. This may or may not be what you want. If not, just put in an actual deletion and decide how you'll want to handle re-structuring the comments tree in that case. We're also gonna use a transaction instead of multiple queries here to keep this operation atomic. Basically, if for some reason, deleting the comment fails, we don't want the update to comments_count to still go through, and vice-versa.


func softDeleteComment(dbPool *pgxpool.Pool, id int, post_id int) error {
	tx, err := dbPool.Begin(context.Background())
	if err != nil {
		return err
	}
	defer tx.Rollback(context.Background())

	_, err = tx.Exec(context.Background(), `UPDATE comments SET deleted_date = NOW() WHERE id = $1`, id)
	if err != nil {
		return err
	}

	_, err = tx.Exec(context.Background(), `UPDATE posts SET comments_count = comments_count - 1 WHERE id = $1`, post_id)
	if err != nil {
		return err
	}

	return tx.Commit(context.Background())
}
Note: I'm using the existence of a deleted date to signify that a comment is soft-deleted because this also tells us when it was deleted. But the downside is that a date requires quite a bit more storage than just a boolean. So if you don't need the date, the better schema would be to just use 'is_deleted' as boolean.

Getting Comments From The Database

We're going to need two queries for getting comments. One for getting a post's comments, which will be pretty simple: Just get all the comments for a post. The other query will need to retreive all the comments on all the posts for a particular user. (If your blog will only ever have one user, this isn't necessary, and you could just retrieve all comments of course). But first, let's look at getting comments for a post:


func getComments(dbPool *pgxpool.Pool, post_id int) ([]Comment, error) {
	rows, err := dbPool.Query(context.Background(), `
		SELECT id as ID, body as Body, author as Author, parent_id as ParentId, created_date as CreatedAt  
		FROM comments
		WHERE post_id = $1 AND deleted_date IS NULL AND is_approved = true 
		ORDER BY created_date;
		`, post_id)

	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	var comments []Comment
	for rows.Next() {
		var c Comment

		if err := rows.Scan(&c.ID, &c.Body, &c.Author, &c.ParentId, &c.CreatedAt); err != nil {
		}
		comments = append(comments, c)
	}

	return comments, err
}

This brings back any comment on a post as long as it has been approved and not deleted. Now, to get all comments for posts of a specific user for admin purposes:


func getCommentsForUser(dbPool *pgxpool.Pool, user_id int) ([]Comment, error) {
	rows, err := dbPool.Query(context.Background(), `
		SELECT comments.id as ID, comments.body as Body, comments.author as Author, comments.parent_id as ParentId, 
		comments.is_approved as IsApproved, comments.created_date as CreatedAt, posts.id as PostId, posts.title as PostTitle  
		FROM posts  
		RIGHT JOIN comments ON comments.post_id = posts.id
		WHERE posts.user_id = $1 AND comments.deleted_date IS NULL ORDER BY comments.created_date DESC;
		`, user_id)

	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	var comments []Comment
	for rows.Next() {
		var c Comment

		if err := rows.Scan(&c.ID, &c.Body, &c.Author, &c.ParentId, &c.IsApproved, &c.CreatedAt, &c.PostId, &c.PostTitle); err != nil {
		}
		comments = append(comments, c)
	}

	return comments, err
}

This query is really, all that complex. The idea is that we're doing a join that selects all the comments and the title of the post they belong to where the post user id is some specific id. Again, this is only necessary if the blog has multiple users, but if there's only one, you could just get away with this:


func getComments(dbPool *pgxpool.Pool) ([]Comment, error) {
	rows, err := dbPool.Query(context.Background(), `
		SELECT id as ID, body as Body, author as Author, parent_id as ParentId, is_approved as IsApproved, created_date as CreatedAt  
		FROM comments
		ORDER BY created_date;`)

	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()

	var comments []Comment
	for rows.Next() {
		var c Comment

		if err := rows.Scan(&c.ID, &c.Body, &c.Author, &c.ParentId, &c.IsApproved, &c.CreatedAt); err != nil {
		}
		comments = append(comments, c)
	}

	return comments, err
}

Now, finally, we need a query that will allow the admin to approve comments before they actually show up:


func approveComment(dbPool *pgxpool.Pool, id int, post_id int) error {
	tx, err := dbPool.Begin(context.Background())
	if err != nil {
		return err
	}
	defer tx.Rollback(context.Background())

	_, err = tx.Exec(context.Background(), `UPDATE comments SET is_approved = true WHERE id = $1`, id)
	if err != nil {
		return err
	}

	_, err = tx.Exec(context.Background(), `UPDATE posts SET comments_count = comments_count + 1 WHERE id = $1`, post_id)
	if err != nil {
		return err
	}

	return tx.Commit(context.Background())
}

We're using a transaction here because again, we need to update two tables, comments and posts, and we want the action to be atomic. If you wanted, you could move the comments_count update to happen when comments are initially added to the table, but I think it makes the most sense to wait until they're actually approved. Otherwise, before a comment is approved, you'll end up with mis-matches between how many comments show up on a post and how many it says they should have. Also, you'd have to explicitly have a 'deny' action that would also decrement the count, otherwise the comments_count would really be showing the number of all submitted comments, whether they were approved or not.

Rendering The Comments Heirarchically

Before we really start adding an looping or variable rendering logic, let's first work out how our comments will work layout wise at a basic level. To ensure that the html is semantic, we're going to have the comments structured as article elements inside of a section. Each article will have a header to hold the date posted and the author, a div to hold the comment body, and a footer for the reply button. The comments section should be nested inside of the main article element.


<style>
    .comment {
        border-bottom: 1px solid black;
        padding: 1rem 0rem;
    }

    .comment p {
        margin-bottom: 1rem;
        font-size: 1rem;
    }

    .child-comment {
        border-left: 1px solid rgba(0, 0, 0);
        padding-left: 1rem;
    }
</style>

<article class="slide-up-animation full-article">
    ...

    <section id="comments">
        <h2>Discussion ({{ .Post.CommentsCount }} Comments)</h2>

        <ul class="comments-list">
            {{ range .Comments }}
            <li>
                <article class="comment {{ if gt .Depth 0 }}child-comment{{ end }}" id="comment-{{ .ID }}" style="margin-left: calc({{ .Depth }} * 1.5rem);">
                    <header class="comment-meta">
                        <time class="date" data-datetime="{{iso .CreatedAt}}">
                            Loading...
                        </time> &#8226; {{ .Author.String }}
                    </header>
                    <div class="comment-body">
                        <p>{{ .Body }}</p>
                    </div>
                    <footer>
                        <a class="button is-link is-small" href="#comment-form" onclick="replyTo('{{ .ID }}')">Reply to {{ .Author.String }}</a>
                    </footer>
                </article>
            </li>
            {{ end }}
        </ul>
    </section>
</article>

Creating the form to add comments

Of course, we'll need a form that allows users to save comments. I'm gonna make the author an optional field, but there are a lot of options you have with this. For example, if this were a site that users had to log into to post, you could just have the comment author saved as a foreign key onto the comment in the database. Then you could pull back the username with a JOIN query when retreiving comments and display it. But we're keeping it simple here and just letting any anonymous user post a comment, so if they don't type anything into that name field, we'll just make it default to 'Anonymous' on the back end.

We'll just put this right under the comments list, but you we could get away with putting it anywhere. Having it pop up in a modal might be a good idea. But we'll just keep it simple and put it at the bottom for now. When the user wants to reply to a certain comment, we'll just set that parent id comment in the app state and auto-scroll down to this form. When the user submits the comment, we'll just send that parent comment id right along with the comment body and the post id so they can be saved in the database.


<form id="comment-form">
    <h3>Leave a Comment</h3>
    <div class="field">
        <label class="label">Name (optional)</label>
        <div class="control has-icons-left has-icons-right">
            <input id="comment-author" class="input" type="text" placeholder="Name" name="author" 
                pattern="^[^&lt;&gt;?;{}()\[\]&quot;&#96;&amp;$#@!%^*+=|\\\/]*$" 
                maxlength="50" 
                title="Name can only contain letters and numbers (max 50 characters)">
            <span class="icon is-small is-left">
                <i class="fas fa-user"></i>
            </span>
            <span class="icon is-small is-right">
                <i class="fas fa-check"></i>
            </span>
        </div>
        <p class="help is-danger" id="first-name-error" style="display: none;">Name can only contain letters and numbers (max 50 characters)</p>
    </div>
    <div class="field">
        <label for="comment-body" class="label">Message</label>
        <textarea id="comment-body" class="textarea mt-2" name="body" placeholder="Say something cool" required
            maxlength="2000"></textarea>
        <p class="help is-danger" id="contact-message-error" style="display: none;">Message contains invalid characters</p>
    </div>
    <button id="submit-comment" class="button is-info" data-post_id="{{ .Post.ID }}">Submit Comment</button>
</form>

Now, we need to add the javascript that will send the comment data to the back end.


<script>
    const submitCommentButton = document.querySelector("#submit-comment")
    let parent_id = ""

    const post_id = submitCommentButton.dataset.post_id

    function replyTo(id) {
        parent_id = id
    }

    submitCommentButton.addEventListener("click", async (e) => {
        e.preventDefault()

        if (document.querySelector("#comment-body").value === "") {
            alert("Comment can't be blank!")
            return
        }

        let cf = new FormData(document.querySelector("#comment-form"))

        cf.set("post_id", post_id)

        if (parent_id) {
            cf.set("parent_id", parent_id)
        }

        console.log({cf})

        await fetch("/comments/add", {
            method: "post",
            body: cf
        }).then(async res => {
            alert(await res.text())
            document.querySelector("#comment-author").value = ""
            document.querySelector("#comment-body").value = ""
        }).catch(async err => {
            alert(await err.text())
        })
    })
</script>

This is a super simple implementation where all we're doing is sending the post id, parent comment id, comment author, and comment body to our back end.

Back End Logic

Now, on the back-end, we need to add some routes to handle adding, deleting, approving, and getting commments. Notice that we're wrapping the approve and delete routes in a middleware that ensures only a logged in user can access them. You'll have to decide what the rules of this middleware are but in general, you'll wanna make sure the user is logged in and the comments are on posts that they own.


http.Handle("/comments", http.HandlerFunc(handleGetComments))
http.Handle("/comments/add", http.HandlerFunc(handleAddComment))
http.Handle("/comments/approve", requireAuth(http.HandlerFunc(handleApproveComment)))
http.Handle("/comments/delete", requireAuth(http.HandlerFunc(handleDeleteComment)))

Here's the handler for adding the comment. It basically just accepts the form input, populating a Comment struct with the data and saving to the db using the insert method we created earlier.


func handleAddComment(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	post_id := r.FormValue("post_id")
	parent_id := r.FormValue("parent_id")
	author := r.FormValue("author")
	body := r.FormValue("body")

	if author == "" {
		author = "Anonymous"
	}

	cs := CommentSubmission{
		Author:   author,
		Body:     body,
		PostId:   post_id,
		ParentId: parent_id,
	}

	val_errs := cs.validateComment()

	if len(val_errs) > 0 {
		http.Error(w, fmt.Sprintf("Form submission not vaild %v", val_errs), http.StatusBadRequest)
		return
	}
	if post_id == "" {
		http.Error(w, "Post Id cannot be empty", http.StatusBadRequest)
		return
	}
	if author == "" {
		http.Error(w, "Author name cannot be empty", http.StatusBadRequest)
		return
	}
	if body == "" {
		http.Error(w, "Body cannot be empty", http.StatusBadRequest)
		return
	}
	user_id := 1

	p_id, _ := strconv.Atoi(post_id)

	comment := Comment{
		UserId: int32(user_id),
		Author: sql.NullString{String: cs.Author, Valid: true},
		PostId: int32(p_id),
		Body:   cs.Body,
	}

	if parent_id != "" {
		pr_id, _ := strconv.Atoi(parent_id)
		comment.ParentId = sql.NullInt32{Int32: int32(pr_id), Valid: true}
	}

	err := insertComment(dbPool, comment)

	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte("Thank you for your comment!"))
}

We're handling the delete by calling the soft delete db function we created earlier.


func handleDeleteComment(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodDelete {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	queryParams := r.URL.Query()
	id := queryParams.Get("id")
	post_id := queryParams.Get("post_id")

	if id == "" {
		http.Error(w, "Missing comment 'id' query parameter: ", http.StatusBadRequest)
		return
	}

	if post_id == "" {
		http.Error(w, "Missing comment 'post_id' query parameter: ", http.StatusBadRequest)
		return
	}

	s_id, _ := strconv.Atoi(id)
	p_id, _ := strconv.Atoi(post_id)

	err := softDeleteComment(dbPool, s_id, p_id)

	if err != nil {
		fmt.Print("Could not delete comment: %v", err)
	}
}

Instead of adding an endpoint that gets comments posts, we'll just update the blog posts endpoint to also bring back comments on a blog post. To make things more performant, we'll use a Go Routine along with a waitgroup to make concurrent calls to get the single blog post, suggested post previews, page theme, and finally, the comments.


func singleBlogPostHandler(w http.ResponseWriter, r *http.Request) {
	user_id := 1

	path := strings.TrimPrefix(r.URL.Path, "/posts/")
	if path == "" || strings.Contains(path, "/") {
		http.Error(w, "Invalid post ID", http.StatusBadRequest)
		return
	}

	var post BlogPost
	var posts []BlogPost
	var comments []Comment
	var theme Theme
	var err1, err2, err3 error

	var wg sync.WaitGroup
	wg.Add(3)

	post, err1 = getSinglePost(dbPool, path)

	go func() {
		defer wg.Done()
		posts, err2 = getPostPreviews(dbPool, user_id, 3, path, 0)

	}()

	go func() {
		defer wg.Done()
		theme, _ = getSingleTheme(dbPool, user_id)

	}()

	var commentTree []*Comment
	var flatComments []*Comment

	go func() {
		defer wg.Done()
		comments, err3 = getComments(dbPool, int(post.ID))
		commentTree = buildTree(comments)
		flatComments = flattenTree(commentTree)
		
	}()

	wg.Wait()

	if err1 != nil {
		log.Printf("err 1: %v", err1)
		http.Redirect(w, r, "/404", http.StatusSeeOther)
	}

	if err2 != nil {
		log.Printf("err 2: %v", err2)
		http.Redirect(w, r, "/404", http.StatusSeeOther)
	}

	if err3 != nil {
		log.Printf("err 3: %v", err3)
	}

	var image string

	if post.ImgLoPath.String != "" {
		image = post.ImgLoPath.String
	} else {
		image = "/static/images/default.png"
	}

	renderTemplate(w, "single-blog-post", "base", map[string]interface{}{
		"Title":       post.Title,
		"Description": post.ContentPreview.String,
		"Keywords":    "software, coding, programming, developer, application development, software engineering",
		"Image":       "https://laddsoftware.com" + image,
		"URL":         "https://laddsoftware.com/posts/" + post.Slug,
		"Post":        post,
		"Comments":    flatComments,
		"Posts":       posts,
		"Theme":       theme,
	})
}

Now, like I mentioned earlier, the strategy to displaying the comments in a structured heirarchy is to create a tree, then flatten it into an array with each comment having a number that denotes how far many levels it should be indented. So we're gonna break that logic up into two methods, buildTree and flattenTree.

Building the Comments Tree

What we need to do is, build a tree structure where every root level comment contains arrays of it's children. If those children have children, they should also contain arrays of their children. The way this will work is that each Comment struct can hold an array of pointers to other Comment structs as it's 'Children'. So all we need to do is loop through the 'comments' rows that we get back from the database, creating a map of each comment's ID to a pointer to it, and if that comment has a parent comment, put a pointer to it in that parent's 'Children' array. And a crucial point is that each child get's it's 'indention depth' set to whatever it's parent's was plus 1. This will make rendering 'nested' comments super easy.


func buildTree(rows []Comment) []*Comment {
	index := make(map[int32]*Comment, len(rows))
	roots := make([]*Comment, 0)

	for i := range rows {
		c := &rows[i]
		c.Children = make([]*Comment, 0)
		index[c.ID] = c
	}

	for i := range rows {
		c := &rows[i]
		if !c.ParentId.Valid { // if no parent, put it in the roots slice
			c.Depth = 0
			roots = append(roots, c)
		} else { // otherwise, find it's parent in memory and append to it's children slice
			parent := index[c.ParentId.Int32]
			if parent != nil { // guard against orphaned comments (e.g. parent was deleted)
				c.Depth = parent.Depth + 1
				parent.Children = append(parent.Children, c)
			}
		}
	}

	return roots
}

Flattening the Comments Tree into an Array of Comments

What we got from the above is a map of id's to comment memory addresses, but what we want to give our front end is just an array of comments. So now, we need to flatten the tree structure into an array. We'll do that by doing a Depth First Traversal of the tree, adding comments to it as we go. The reason it needs to be a depth first traversal, is we need to traverse each root level comment all the way down to it's last child before going on to the next to ensure that each comment is in the same order as the tree. We'll do this by walking through each root level comment of the tree, appending that comment to the results slice, then recursively calling on it's children. Even though there's no explicit base case in the recursion, we can see that it only continues as long as there are children that it hasn't been called on. So each descendant will also be added to the results slice in the order they appear in the tree and when it has walked through all the descendants of a comment, it's done.


func flattenTree(roots []*Comment) []*Comment {
	result := make([]*Comment, 0)
	var walk func(c *Comment) // in Go, you can't declare a named func inside of another one
	walk = func(c *Comment) { // but you can assign an anonymous func to a local variable
		result = append(result, c) // append it to result slice
		for _, child := range c.Children {
			walk(child) // recursively call walk on each child
		}
	}
	for _, root := range roots {
		walk(root) // initially call walk on all the roots (which will recurse their children)
	}
	return result
}

Now, when we send the comments to the front end we can just loop through them and indent each one based on it's depth.


style="margin-left: calc({{ .Depth }} * 1.5rem);"

Like I mentioned above, you could skip the flattening step and render the tree using a recursive template. Go's "html/template" package doesn't really have support for rendering recursive templates 'out of the box', but you could pull it of with a custom template function. I might explore that in another post, but for now, this works pretty well.

The Admin Dashboard, Approving and Deleting Comments

So the last thing we need to complete this feature is the ability to approve and delete comments. You could handle moderation of comments in a lot of different ways, but I think the most simple and effective way is just letting the owner of the post approve or delete them. We've already added all the back end and database logic for that, so all we need is the view portion:


{{define "content"}}
<ul id="item-list" class="item-list">
    {{range .Comments}}
        <li id="item-{{.ID}}" class="item">
            <div class="title is-4 mb-0">
                <span>{{ .Author.String }}</span>
            </div>
            <div>
            <i>
                On: {{ .PostTitle.String }}
            </i>
            </div>
            <time class="date" data-datetime="{{iso .CreatedAt}}">
                Loading...
            </time>
            <div>
                {{ .Body }}
            </div>

            <div class="buttons mt-2">
                <button {{if .IsApproved}}disabled{{end}} class="approve-comment button is-success is-small mt-2" id="{{.ID}}" data-post_id="{{ .PostId }}">Approve</button>
                <button class="delete-comment button is-danger is-small mt-2" id="{{.ID}}" data-post_id="{{ .PostId }}">Delete</button>
            </div>
        </li>
    {{end}}
</ul>

<script>
    const approveCommentBtns = document.querySelectorAll(".approve-comment")
    const deleteCommentBtns = document.querySelectorAll(".delete-comment")

    async function approveComment(id, post_id) {
        await fetch(`comments/approve?id=${id}&post_id=${post_id}`, {
            method: "PATCH"
        }).then(res => {
            return res.text()
        })
    }

    async function deleteComment(id, post_id) {
        await fetch(`comments/delete?id=${id}&post_id=${post_id}`, {
            method: "delete"
        }).then(res => {
            return res.text()
        })
    }

    for (let index = 0; index < approveCommentBtns.length; index++) {
        const element = approveCommentBtns[index]
        const id = element.id
        const post_id = element.dataset.post_id
        element.addEventListener("click", async () => {
            const conf = confirm(`Are you sure you want to approve entry ${id}?`)

            if (conf) {
                await approveComment(id, post_id)
                element.disabled = true
            }
        })
    }

    for (let index = 0; index < deleteCommentBtns.length; index++) {
        const element = deleteCommentBtns[index]
        const id = element.id
        const post_id = element.dataset.post_id
        element.addEventListener("click", async () => {
            const conf = confirm(`Are you sure you want to delete entry ${id} from the list?`)

            if (conf) {
                await deleteComment(id, post_id)
                element.closest('li').remove()
            }
        })
    }
</script>
{{end}}

This will just send requests to our back end to update the is_approved and deleted_date fields on the comments.

So that's basically the full implementation! I'd consider this an MVP (minimum viable product), so there's a lot I'd add to make this full 'production ready'. A few things that come to mind are:

  • 1. Caching the flattened tree so that logic doesn't have to run every time every time a post is retreived
  • 2. Lazy-Loading comments either when that section is scrolled to or a button is clicked, to save un-necessary computation
  • 3. Adding a captcha to the comments form, or requiring login to prevent bot comments
  • 4. Adding pagination for really long comment threads and admin approval page
  • 5. Adding the ability to sort comments in either ascending or descending order
  • 6. Adding the ability to upvote or downvote commments for logged in users
  • 7. Adding custom modals instead of using built in browser alerts and confirms

At least this is how I did it for this blog! Leave a comment below and let me know if you think this could be done a better way or if there's something you'd change in the implementation. Anyway, thanks for reading til the end!

Discussion (1 Comments)

  • • Jason

    Of course I have to call out that I did this entirely 'for fun' since you could just build a blog with Wordpress or Ghost or something and get all this functionality (and a lot more) entirely for free with no coding required, but I like coding and figured this would be a good chance to practice some Go, so... yeah!

Leave a Comment

Sorting Algorithms in Go

The fundamental sorting algorithms are Bubble Sort, Insertion Sort, Selection Sort, Quick Sort, Merge Sort, and Heap Sort. Out of these, Quick, Merge, and Heap Sort offer the best efficiency. Here's a breakdown of how they work, implemented in Go.

Binary Search in Go

Binary Search is an algorithm that is used to find out if a number exists in a list, and if so, what it's position in that list is. The strategy is to "divide and conquer", which makes it much faster and more efficient than checking every single number.

Working with The Graph Data Structure in Go

Graphs are data structures made up of a bunch of individual nodes that connect to each other. Their main purpose is to model the connection relationships between things. Another name for graphs that you've likely already heard of is "networks".