Task 14: Comment on Posts — Backend

In this task, we will update the backend (Nest.js) app to enable the addition of comments to a post.

Step 1: Create Comment Entity

Recall that in TypeORM, entities are classes that map to database tables. Each entity represents a table in the database, and instances of entities represent rows within that table.

To define the Comment entity and its relationship with the Post and User entities, follow these steps:

Create a new file named comment.entity.ts within the comments directory in the api/src folder. In this file, define the Comment entity and its properties, which will correspond to the columns in the database table.

import { Post } from "src/posts/post.entity";
import { User } from "src/user/user.entity";
import {
  Column,
  CreateDateColumn,
  Entity,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from "typeorm";

@Entity()
export class Comment {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column()
  content: string;

  @CreateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" })
  timestamp: Date;

  @ManyToOne(() => User, (user) => user.posts)
  @JoinColumn({ name: "userId" })
  user: User;

  @Column()
  userId: number;

  @ManyToOne(() => Post, (post) => post.comments)
  @JoinColumn({ name: "postId" })
  post: Post;

  @Column()
  postId: string;
}

In the Comment entity, we define a many-to-one relationship with the User entity. This means that each comment can be associated with one user, and each user can write many comments.

To establish this relationship, update the User entity to include a collection of Comment entities. This allows us to directly access a user's comments from the User entity.

// Add this import at the top of the file
import { Comment } from "src/comments/comment.entity";

// Add this inside the User entity class
@OneToMany(() => Comment, (comment) => comment.user)
comments: Comment[];

Similarly, in the Comment entity, we define a many-to-one relationship with the Post entity. This means that each comment can be associated with one post, and each post can have multiple comments.

To complete this relationship, update the Post entity to include a collection of Comment entities. This allows us to directly access the comments of a post from the Post entity.

// Add this import at the top of the file
import { Comment } from "src/comments/comment.entity";

// Add this inside the Post entity class
@OneToMany(() => Comment, (comment) => comment.post)
comments: Comment[];

Please stage and commit the changes:

git add .
git commit -m "Add Comment entity and establish relationships with Post and User entities"

Step 2: Set up Comments module, service, and controller

Let's proceed with setting up the Comments module, service, and controller. We will use the Nest CLI to streamline this process. Open the terminal and change the directory to the api folder.

Generate Comments Module

First, we will generate the Comments module:

nest generate module comments

This command will create a comments.module.ts file inside the api/src/comments directory. It will also update the api/src/app.module.ts to include the CommentsModule.

Let’s update the comments.module.ts: import the TypeOrmModule and the Comment entity to register it:

import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Comment } from "./comment.entity";

@Module({
  imports: [TypeOrmModule.forFeature([Comment])],
})
export class CommentsModule {}

Generate Comments Service

Next, we'll generate the Comments service. The service will contain the logic for handling CRUD operations on comments.

nest generate service comments

The CLI will create comments.service.ts and comments.service.spec.ts files in the api/src/comments directory. It will also update the comments.module.ts to include the CommentsService as a provider.

Generate Comments Controller

Finally, we will generate the Comments controller. This controller will handle incoming HTTP requests and delegate them to the Comments service for processing.

To generate the controller, run the following command in your terminal:

nest generate controller comments

This command will create two files, comments.controller.ts and comments.controller.spec.ts, in the api/src/comments directory. It will also update the comments.module.ts file to include the CommentsController as a controller.

Commit Changes

After running these commands, the api/src/comments folder should contain the following files:

.
├── comment.entity.ts
├── comments.controller.spec.ts
├── comments.controller.ts
├── comments.module.ts
├── comments.service.spec.ts
└── comments.service.ts

Please use the "prettier" tool to format these files. After formatting, stage and commit the changes.

git add .
git commit -m "Set up Comment module, service, and controller"

Step 3: Create Comments DTOs

In this step we will create Data Transfer Objects for Comment entity. Recall we use DTOs to define the structure of the data being transferred between different layers or components of our application.

Create Comment DTO

First, we'll create a DTO to define the structure of the data for creating a new comment. This will be used to capture the incoming request data.

Create a file named comment-create.dto.ts within the api/src/comments directory and define the DTO:

import { IsNotEmpty, IsString } from "class-validator";

export class CreateCommentDTO {
  @IsString()
  @IsNotEmpty({ message: "Content cannot be empty" })
  content: string;
}

We're using class validators to ensure that the content field is a non-empty string.

Comment Response DTO

Create a file named comment-response.dto.ts inside the api/src/comments directory to define the structure of the data when a comment is returned in a response.


import { User } from "src/user/user.entity";

export class CommentResponseDTO {
  id: string;
  content: string;
  timestamp: Date;
  userId: number;
  postId: string;
  user?: User;
}

Notice that this response optionally includes information about the user who created the comment which will be helpful for the frontend to display that data next to the comment.

Please stage and commit the changes:

git add .
git commit -m "Add comment create and comment response DTOs"

Step 4: Implement Comments Service

In this step, we will implement the CommentsService class. To keep it simple, we will add only two operations: to create a comment, and to get all comments.

The CommentsService Constructor

We start by adding a constructor to the CommentsService:

import { Injectable } from "@nestjs/common";
import { Repository } from "typeorm";
import { Comment } from "./comment.entity";
import { InjectRepository } from "@nestjs/typeorm";

@Injectable()
export class CommentsService {
  constructor(
    @InjectRepository(Comment)
    private readonly commentRepository: Repository<Comment>,
  ) {}

	// TODO: Add operations
}

The constructor takes in a parameter commentRepository of type Repository<Comment>. The @InjectRepository() decorator is used to inject the Comment entity repository from TypeORM. This allows us to access the methods provided by the repository, such as querying the database for comments.

By injecting the commentRepository into the CommentsService, we can use it to perform CRUD (Create, Read, Update, Delete) operations on the Comment entity in the database.

Create Comment

Next, let's add an operation to create a comment:

// Creates a new instance of the Comment entity and saves it to the database.
// Returns the newly created comment.
async create(
  createCommentDto: CreateCommentDTO,
  postId: string,
  userId: number,
): Promise<Comment> {
  const comment = this.commentRepository.create({
    ...createCommentDto,
    postId, // Associate the comment with a post
    userId, // Associate the comment with a user
  });

  return this.commentRepository.save(comment);
}

The create method is responsible for creating a new comment. It takes three parameters: createCommentDto, postId, and userId.

  • createCommentDto is an object that contains the content of the comment.
  • postId is the ID of the post that the comment is associated with.
  • userId is the ID of the user who created the comment.

Inside the method, a new instance of the Comment entity is created and populated with the content from createCommentDto, as well as the postId and userId values. The commentRepository.save() method is then called to save the new comment to the database.

The method returns a promise that resolves to the newly created comment.

Increment Comment Counts

To keep track of the number of comments for a Post, we need to update the commentCount attribute whenever a comment is added or removed. Let's add an operation to the PostsService to increment the commentCount:

async incrementCommentCounter(id: string): Promise<Post | null> {
  const post = await this.findOne(id);
  if (!post) {
    return null;
  }

  post.commentCount += 1;
  await this.postRepository.save(post);
  return post;
}

The incrementCommentCounter operation is responsible for increasing the comment count for a specific post. Here's how it works:

  • It takes the ID of the post as a parameter.
  • It retrieves the post from the database using the findOne method.
  • If the post is not found, it returns null.
  • If the post is found, it increments the commentCount attribute by 1.
  • It then saves the updated post to the database using the postRepository.save method.
  • Finally, it returns the updated post.

We will utilize this method in the create function of the CommentsService to increment the comment count for the associated post.

Add PostsService as a Provider

To be able to use incrementCommentCounter from PostsServices within the create method of the CommentsService, we need to make it accessible in the comments module. To achieve this, update CommentsModule as shown below:

  import { Module } from "@nestjs/common";
  import { CommentsService } from "./comments.service";
  import { CommentsController } from "./comments.controller";
  import { TypeOrmModule } from "@nestjs/typeorm";
  import { Comment } from "./comment.entity";
+ import { PostsService } from "src/posts/posts.service";
+ import { Post } from "src/posts/post.entity";

  @Module({
-   imports: [TypeOrmModule.forFeature([Comment])],
+   imports: [TypeOrmModule.forFeature([Comment, Post])],
-   providers: [CommentsService],
+   providers: [CommentsService, PostsService],
    controllers: [CommentsController],
  })
  export class CommentsModule {}

Increment Comment Counts when Creating Comment

Let's update the constructor of the CommentsService as follows:

  constructor(
    @InjectRepository(Comment)
    private readonly commentRepository: Repository<Comment>,
+   private readonly postsService: PostsService,
  ) {}

Make sure to import the PostsService. Then, update the create operation as follows:

  async create(
    createCommentDto: CreateCommentDTO,
    postId: string,
    userId: number,
  ): Promise<Comment> {
    const comment = this.commentRepository.create({
      ...createCommentDto,
      postId, // Associate the comment with a post
      userId, // Associate the comment with a user
    });

+   // Increment the comment counter in the associated post
+   await this.postsService.incrementCommentCounter(postId);

    return this.commentRepository.save(card);
  }

Find all Comments

Next, we will add an operation to retrieve all comments:

// Returns all comments that match the given criteria.
async findAll(
  limit: number,
  offset: number,
  postId?: string,
  userId?: number,
  search?: string,
  withUserData?: boolean,
  withPostData?: boolean,
): Promise<Comment[]> {
  const content = search ? ILike(`%${search}%`) : undefined;
  const relations = [];

  if (withUserData) {
    relations.push("user");
  }

  if (withPostData) {
    relations.push("post");
  }

  const comments = await this.commentRepository.find({
    take: limit,
    skip: offset,
    where: [
      {
        postId,
        userId,
        content,
      },
    ],
    order: {
      timestamp: "DESC",
    },
    relations,
  });

  return comments;
}

The findAll operation retrieves all comments that match the specified criteria. It accepts several parameters:

  • limit and offset define the pagination of the results, allowing you to fetch a specific number of comments starting from a particular position.
  • postId and userId are optional filters to narrow down the comments based on the associated post or user.
  • search allows you to search for comments based on a specific keyword or phrase.
  • withUserData and withPostData are flags indicating whether to include the associated user and post data in the returned comments.

Inside the method, the commentRepository.find() method is called with the provided parameters. It retrieves the comments from the database based on the specified criteria. The comments are ordered in descending order by their timestamp. Note that the find operation of the repository is used instead of the createQueryBuilder method. While the createQueryBuilder method offers more flexibility and control for constructing complex queries, the find operation provides a simpler and more convenient way to fetch entities from the database.

The method returns a promise that resolves to an array of comments matching the specified criteria.

Overall, the findAll operation allows for flexible fetching of comments based on various criteria, supports pagination, and provides options for including associated data.

Aside: With the createQueryBuilder method, you can manually construct queries using SQL-like syntax, including conditions, joins, ordering, and aggregations. This allows you to handle more advanced scenarios and perform custom operations that may not be possible with the find operation alone.

On the other hand, the find operation of the repository provides a simpler and more convenient way to fetch entities from the database. It abstracts away the complexity of query construction and provides a straightforward API for common retrieval scenarios. It allows you to specify filtering criteria, ordering, pagination, and eager loading of related entities using a declarative syntax.

In general, you can use the find operation for most basic retrieval needs, while the createQueryBuilder method is suitable for more complex queries and situations where fine-grained control is required.

It's important to note that both approaches have their merits, and the choice between them depends on the specific requirements and complexity of your application's data access layer.

Commit Changes

Although we could have included additional operations in the CommentsService class, we decided to keep it simple by implementing only two operations: creating a comment and retrieving all comments.

To stage and commit the changes, run the following commands:

git add .
git commit -m "Implement findAll and create methods in CommentsService"

Step 5: Implement Comments Controller

In this step, we focus on implementing the CommentsController class in our application. This involves creating operations for both creating a comment and retrieving all comments. Our objective is to ensure that these operations align well with the previously implemented CommentsService.

Initial Controller Setup

We start by modifying the comments.controller.ts file located in the api/src/comments folder. Initially, it appears as follows:

import { Controller } from "@nestjs/common";

@Controller("comments")
export class CommentsController {}

We modify this to include necessary dependencies and guards:

import { Controller, UseGuards } from "@nestjs/common";
import { CommentsService } from "./comments.service";
import { JwtAuthGuard } from "src/guards/jwt-auth.guard";

@UseGuards(JwtAuthGuard)
@Controller("posts/:postId/comments")
export class CommentsController {
  constructor(private readonly commentsService: CommentsService) {}
}

In this revised version, we have added a constructor and decorated the controller with UseGuards and JwtAuthGuard to secure our endpoints. This ensures that only authenticated users can interact with comments. The Controller decorator has been updated to reflect the nested nature of comments within posts.

Adding Create Comment Operation

Next, we expand the controller to include a method for creating comments:

@Post()
async create(
  @Body() createCommentDto: CreateCommentDTO,
  @Param("postId") postId: string,
  @UserId() userId: number,
): Promise<CommentResponseDTO> {
  return await this.commentsService.create(createCommentDto, postId, userId);
}

The create method relies on the CommentsService to add a new comment to a post. The method extracts the necessary data from the request body and parameters, and the UserId decorator is used to retrieve the ID of the currently authenticated user.

Note the @Param decorator, which extracts the postId parameter from the nested endpoint posts/:postId/comments.

Implementing DTOs for Comment Queries

For handling queries, we introduce two new Data Transfer Objects (DTOs):

  1. Define FindCommentsQueryDTO in find-comments-query.dto.ts (in the api/src/comments folder):

    import {
      IsInt,
      IsOptional,
      IsPositive,
      Min,
      IsString,
      Max,
      IsBoolean,
    } from "class-validator";
    
    export class FindCommentsQueryDTO {
      @IsInt()
      @IsPositive()
      @Min(1)
      @Max(50)
      @IsOptional()
      limit: number = 10;
    
      @IsInt()
      @Min(0)
      @IsOptional()
      offset: number = 0;
    
      @IsString()
      @IsOptional()
      search?: string;
    
      @IsBoolean()
      @IsOptional()
      withPostData?: boolean;
    
      @IsBoolean()
      @IsOptional()
      withUserData?: boolean;
    }
    
  2. Define FindCommentsResponseDTO in find-comments-response.dto.ts (in the api/src/comments folder):

    import { CommentResponseDTO } from "./comment-response.dto";
    
    export class FindCommentsResponseDTO {
      limit: number;
      offset: number;
      search?: string;
      withPostData?: boolean;
      withUserData?: boolean;
      data: CommentResponseDTO[];
    }
    

These DTOs are helpful for validating and structuring the input and output of our comments query operation.

Retrieving Comments

We then update comments.controller.ts to handle comment retrieval:

@Get()
async findAll(
  @Param("postId") postId: string,
  @Query() query: FindCommentsQueryDTO,
): Promise<FindCommentsResponseDTO> {
  const { limit, offset, search, withPostData, withUserData } = query;

  const comments = await this.commentsService.findAll(
    limit,
    offset,
    postId,
    undefined,
    search,
    withUserData,
    withPostData,
  );

  return {
    limit,
    offset,
    search,
    withUserData,
    withPostData,
    data: comments.map((comment) => {
      if (withUserData) {
        delete comment.user.password;
      }
      return comment;
    }),
  };
}

The findAll method, which leverages the CommentsService to fetch comments based on the provided query parameters. It returns a structured response encapsulating the comments data.

Testing the Application

After these changes, test the API in Postman to ensure the new functionalities work as expected. This includes creating users, posts, and comments, and retrieving comments for a specific post.

Committing Changes

Finally, we stage and commit these changes to our version control system:

git add .
git commit -m "Add create and findAll methods to CommentsController"