Task 8: Implement Posts CRUD

Step 1: Create Post entity

Before we dive into code, it's essential to understand the concept of relational databases and the importance of relationships between different entities.

Relational Databases and Relations

Relational databases store data in tables, which can be thought of as analogous to classes in object-oriented programming. Each table represents a type of entity (e.g., users, posts, comments), and each row in a table represents an instance of that entity.

Relationships between entities are fundamental in relational databases. They define how one entity relates to another and allow us to perform complex queries across multiple tables. The most common types of relationships are:

  • One-to-One: This type of relationship occurs when a row in one table is linked to one, and only one, row in another table. For example, in our database, we can introduce a profile table where each user will have a profile. This represents a one-to-one relationship.
  • One-to-Many: Where a single row in one table can be linked to many rows in another table. This is the typical relationship between users and posts, where one user can have many posts.
  • Many-to-One: The inverse of one-to-many, where many rows in one table are linked to a single row in another table.
  • Many-to-Many: This refers to a relationship where rows in one table can be linked to multiple rows in another table, and vice versa. For example, if we were to introduce a "repost" feature (similar to retweeting), then a post could be reposted by many users, and a user can repost many posts. This captures a many-to-many relationship between users and (re)posts.

Currently, we're interested in the one-to-many relationship between users and posts, as one user can create many posts but each post is associated with one user.

By defining these relationships in our entities, we can leverage TypeORM's ability to automatically join tables and manage related data efficiently.

With this foundational knowledge, we can proceed to define the Post entity in our application.

Create Post Entity and Set Up Relationship with User

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

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

Create a new file named post.entity.ts within a posts directory in the src folder. This file will define the Post entity and its properties, which will correspond to the columns in the database table.

import { User } from 'src/user/user.entity';
import {
  Column,
  CreateDateColumn,
  Entity,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
export class Post {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  content: string;

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

  @Column({ nullable: true })
  image: string;

  @Column({ default: 0 })
  likeCount: number;

  @Column({ default: 0 })
  commentCount: number;

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

  @Column()
  userId: number;
}

In the Post entity, we define a many-to-one relationship with the User entity. This means that each post can be associated with one user, but each user can have many posts.

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

@Column()
userId: number;

The @ManyToOne decorator is used to define this relationship. The @JoinColumn decorator specifies the name of the column that will hold the foreign key in the posts table to the users table.

💡 A foreign key is a column (or a set of columns) in a table that refers to the primary key of another table. It establishes a relationship between two tables by linking the data in one table to the data in another table.

The @JoinColumn({ name: 'userId' }) decorator in the Post entity specifies the column name in the table that will be used to establish the foreign key relationship with the User table.

In the context of the Post entity, the userId column is the foreign key that references the id column of the User entity. This allows us to associate each post with the corresponding user in the database. By doing so, we've also enabled referential integrity, ensuring that every post is associated with a valid user. TypeORM will automatically create the necessary foreign key constraint in the database and handle the retrieval of associated entities when querying entities.

To complete the relationship, update the User entity to include a collection of Post entities. This will allow us to access a user's posts directly from the User entity.

// Add this import at the top of the file
import { Post } from 'src/posts/post.entity';

// Put this inside the User entity class
@OneToMany(() => Post, (post) => post.user)
posts: Post[];

The @OneToMany decorator is used to establish the inverse side of the relationship, where a user can have multiple posts.

The () => Post, post => post.user inside the @OneToMany decorator defines the target entity and the inverse side of the relationship.

  • () => Post: Specifies the target entity, which is the Post entity in this case.
  • post => post.user: Defines the property on the Post entity that references the User entity. In this case, the user property on the Post entity represents the user who created the post.

Similarly, the () => User, user => user.posts inside the @ManyToOne decorator in the Post entity specifies the target entity and the inverse side of the relationship.

  • () => User indicates that the target entity is the User entity.
  • user => user.posts establishes the inverse side of the relationship, indicating the property on the User entity that references the Post entity. In this case, the posts property on the User entity represents the posts created by the user.

By specifying these configurations, TypeORM knows how to establish the relationship between the User entity and the Post entity. It allows us to access a user's posts through the posts property on the User entity, just as we would be able to access a user who created the post through the Post entity.

Aside: The @CreateDateColumn({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) decorator is applied to the timestamp property of the Post entity. It specifies that the column should have the data type timestamptz, which represents a timestamp with time zone.

The default: () => 'CURRENT_TIMESTAMP' option sets the default value of the column to the current timestamp. This means that if no value is provided for the timestamp property when creating a new Post entity, it will automatically be populated with the current timestamp.

Using the @CreateDateColumn decorator simplifies tracking the creation time of a post without requiring manual timestamp value assignment in the code.

Aside: The @PrimaryGeneratedColumn('uuid') specifies that the id column in the Post entity will be a primary key column with a UUID (Universally Unique Identifier) data type. The uuid data type is commonly used for primary keys because it generates unique identifiers that are highly unlikely to collide with each other.

In contrast, the User entity uses @PrimaryGeneratedColumn() without any arguments, which means that the primary key values for users will be auto-incrementing integers. This is a common choice for primary keys in many scenarios.

The decision to use auto-incrementing integers or UUIDs for primary keys depends on the specific requirements of the application. Auto-incrementing integers are generally more compact and easier to read, while UUIDs provide better uniqueness guarantees across distributed systems.

Please stage and commit the changes:

git add .
git commit -m "Create Post entity"

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

Let's proceed with setting up the Posts module, service, and controller. We will now use the Nest CLI to streamline this process. The CLI will help us quickly generate the necessary files and boilerplate code.

Generate Posts Module

First, we will generate the Posts module. This module will act as a dedicated space for all the posts-related functionalities. Use the Nest CLI to generate the module:

nest generate module posts

This command will create a posts.module.ts file inside a posts directory. It also updates the app.module.ts to include the PostsModule.

Generate Posts Service

Next, we'll generate the Posts service. The service will contain the business logic for handling post operations such as creation, retrieval, updating, and deletion.

nest generate service posts

The CLI will create posts.service.ts and posts.service.spec.ts files in the posts directory. The posts.service.spec.ts file is for testing the service operations, while the posts.service.ts file is where you will implement the logic to interact with the database.

Generate Posts Controller

Lastly, we will generate the Posts controller. The controller will handle incoming HTTP requests and delegate to the Posts service for processing.

nest generate controller posts

This command will create posts.controller.ts and posts.controller.spec.ts files in the posts directory.

After running these commands, the posts folder should have the following files:

src
└── posts
    ├── post.entity.ts
    ├── posts.controller.spec.ts
    ├── posts.controller.ts
    ├── posts.module.ts
    ├── posts.service.spec.ts
    └── posts.service.ts

Now, let's begin preparing these files before we dive into implementing each one.

Posts Module

In posts.module.ts, import the TypeOrmModule and the Post entity to register it:

import { Module } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostsController } from './posts.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; // <-- add this line
import { Post } from './post.entity'; // <-- add this line

@Module({
  imports: [TypeOrmModule.forFeature([Post])], // <-- add this line
  providers: [PostsService],
  controllers: [PostsController],
})
export class PostsModule {}

Posts Service

In posts.service.ts, inject the repository for the Post entity:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Post } from './post.entity';

@Injectable()
export class PostsService {
  constructor(
    @InjectRepository(Post)
    private postRepository: Repository<Post>,
  ) {}

  // We'll add methods for handling CRUD operations here
}

Posts Controller

In posts.controller.ts, inject the service for the Post entity:

import { Controller } from '@nestjs/common';
import { PostsService } from './posts.service';

@Controller('posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  // We will add handlers for CRUD endpoints here
}

These are the basic setup steps for the Posts module, service, and controller. We will fill in the implementations for each CRUD operation in the upcoming steps.

Please stage and commit these changes:

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

Step 3: Implement POST endpoint

This step involves creating DTOs, service method, and controller action to handle the creation of a new post.

1. Create Post DTO

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

Create a file named create-post.dto.ts within the posts directory and define the DTO:

import { IsNotEmpty, IsOptional, IsString } from 'class-validator';

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

  @IsOptional()
  @IsString()
  image?: string;
}

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

2. Post Response DTO

Create a file named post-response.dto.ts within the posts directory to define the structure of the data when a post is returned in a response.

export class PostResponseDto {
  id: string;
  content: string;
  timestamp: Date;
  image?: string;
  likeCount: number;
  commentCount: number;
}

This DTO will be used when we want to send back post data in the response.

Notice that this response does not include information about the user who created the post. It might be beneficial to include the user information in the PostResponseDto. If you recall, on the frontend with our mock data, we had a mock API function called fetchPosts that would return an array of posts with user data (with the type Promise<PostWithUserData[]>). We can do something similar when implementing the GET endpoint, so that user data is included in each post. However, for now, I have decided to exclude that data.

2. Update Posts Service

Now, update the PostsService to include a method to create a new post. Add the following create operation to the PostsService class (and update the import statements as needed):

async create(createPostDto: CreatePostDto, userId: number): Promise<Post> {
  const post = await this.postRepository.create({
    ...createPostDto,
    userId,
  });
  return this.postRepository.save(post);
}

Here, we're creating a new post and saving it using the repository. The user ID is assumed to be passed along with the request, which we will handle in the controller.

3. Update Posts Controller

Next, let's create the controller action to handle the POST request.

Add the following create operation to PostsController class (and update the import statements as needed):

@Post()
async create(@Body() createPostDto: CreatePostDto): Promise<PostResponseDto> {
  const userId = 1; // TODO: get userId from JWT token
  const post = await this.postsService.create(createPostDto, userId);
  delete post.userId;
  return post;
}

In the final implementation, the userId should come from the authenticated user context, but for now, we can hardcode it for testing purposes.

4. Testing with Postman

With the POST endpoint implemented, you can now test it using Postman.

  • Set up a new POST request to your server's /posts endpoint.

  • Set the body type to raw and format to JSON.

  • Include the content (and optionally an image URL) in the JSON body of the request. For example, something like this:

    {
      "content": "This is a new post",
    }
    
  • Send the request and ensure that you receive the expected response, which should be the new post object with an ID, content, etc.

Once you have tested the endpoint to ensure that it's functioning correctly, commit your changes:

git add .
git commit -m "Implement POST endpoint for creating posts"

This completes the implementation of the POST endpoint for creating new posts.

Step 4: Implement GET (one) endpoint

We'll now create the functionality to retrieve a single post by its ID. This involves adding a new method to the service and a new route to the controller.

1. Update Posts Service

First, we need to update the PostsService to include a method that retrieves a post by its ID. Here’s how you might add this functionality:

In posts.service.ts, add the following method (and update the import statements as needed):

async findOne(id: string): Promise<Post | null> {
  return this.postRepository.findOneBy({ id });
}

This method will try to find a single post by its ID. If it doesn't find a post with the given ID, it returns null.

2. Update Posts Controller

Next, we'll update the PostsController to handle requests to get a single post by ID.

In posts.controller.ts, add the following operation (and update the import statements as needed):

@Get(':id')
async findOne(@Param('id') id: string): Promise<PostResponseDto> {
  const post = await this.postsService.findOne(id);
  if (!post) {
    throw new NotFoundException(`Post with ID ${id} not found`);
  }
  delete post.userId;
  return post;
}

Here, we are adding a new route that will capture the id parameter from the URL. The controller calls the service's findOne method. If the service is unable to find a post with the given ID, it throws a NotFoundException. If the target post is found, we exclude the userId before returning it, as our PostResponseDto does not include the user ID.

The @Param('id') decorator is used in the controller method to retrieve the value of the id parameter from the URL. It captures the id parameter from the route /posts/:id, where :id represents the dynamic value that will be passed in the URL. This allows you to access the dynamic parts of the URL and use them in your controller logic.

For example, if the route is /posts/123, the @Param('id') decorator will retrieve the value 123 and assign it to the id variable in the controller method.

3. Testing with Postman

Test this new functionality using Postman by setting up a new GET request:

  • Set up a new GET request to your server's /posts/{id} endpoint, replacing {id} with the actual ID of the post you want to retrieve.
  • Send the request and ensure that you receive the expected response, which should be the post object corresponding to the ID you've provided.

If the post is found, it should return the post data; if not, it should return a 404 Not Found status.

After confirming the route works as expected, make sure to commit your changes:

git add .
git commit -m "Implement GET endpoint for retrieving a single post"

This completes the implementation of the GET endpoint for retrieving a specific post by its ID.

Step 5: Implement PATCH endpoint

In many social platforms, updates to posts may either be disallowed after posting or permitted within a certain timeframe. For the purposes of our Posts application, we will demonstrate the implementation of an update operation without such restrictions.

1. Update Post DTO

First, let's define a DTO for updating a post. This DTO will allow partial updates.

Create a file named update-post.dto.ts within the posts directory and define the DTO:

import { IsOptional, IsString } from 'class-validator';

export class UpdatePostDto {
  @IsOptional()
  @IsString()
  content?: string;

  @IsOptional()
  @IsString()
  image?: string;
}

This DTO uses the IsOptional decorator, allowing us to send only the fields we want to update without requiring all the properties that a CreatePostDto might require.

2. Update Posts Service

Next, update the PostsService to include a method for updating an existing post:

In posts.service.ts, add the update method (and update the import statements as needed):

async update(id: string, updatePostDto: UpdatePostDto): Promise<Post | null> {
  const post = await this.postRepository.preload({ id, ...updatePostDto });
  if (!post) {
    return null;
  }
  return this.postRepository.save(post);
}

The preload method creates a new entity based on the object passed, where the id is required for TypeORM to determine which entity to load from the database. It then merges the provided updatePostDto with the found entity. If the entity does not exist in the database, preload returns undefined, and we return null.

The save operation will update the entity if it already exists in the database (otherwise it will add the entity to the database).

3. Update Posts Controller

Now, let's update the PostsController to handle the PATCH request:

In posts.controller.ts, add the update method (and update the import statements as needed):

@Patch(':id')
async update(
  @Param('id') id: string,
  @Body() updatePostDto: UpdatePostDto,
): Promise<PostResponseDto> {
  const post = await this.postsService.update(id, updatePostDto);
  if (!post) {
    throw new NotFoundException(`Post with ID ${id} not found`);
  }
  delete post.userId;
  return post;
}

Here, we invoke the update method on the PostsService class to update the post. If the post does not exist in the database, the service will return null, and we throw a NotFoundException.

If the post exists, it will be updated. We remove the userId from the updated post to exclude it from the response, ensuring consistency with the previous endpoints.

Aside: We could have used PUT for update instead of PATCH. The main difference between the PUT and PATCH HTTP operations lies in their intended use cases for updating resources.

PUT is used to completely replace an existing resource with a new representation. When making a PUT request, the entire resource is sent in the request payload, including any fields that are not being updated. The server then replaces the existing resource with the new representation provided in the request.

On the other hand, PATCH is used to make partial updates to an existing resource. When making a PATCH request, only the specific fields that need to be updated are sent in the request payload. The server then applies these updates to the existing resource, leaving the other fields unchanged.

In summary:

  • Use PUT when you want to completely replace a resource.
  • Use PATCH when you want to make partial updates to a resource.

In our case, it is more appropriate to use a PATCH request to update a post, as we only make partial updates to it.

4. Testing with Postman

You can test the PATCH endpoint as follows:

  • Set up a PATCH request in Postman to your server's /posts/{id} endpoint, with {id} being the ID of the post to update.
  • Set the body type to raw and the format to JSON.
  • Include the fields you wish to update in the JSON body of the request.
  • Send the request and confirm that you receive the updated post object as a response.

After verifying the endpoint's functionality, commit your changes:

git add .
git commit -m "Implement PATCH endpoint for updating posts"

This wraps up the implementation of the PATCH endpoint, allowing for updates to posts.

Step 6: Implement DELETE endpoint

As mentioned earlier, on certain social platforms, you might not be able to delete your posts after posting or you might have a specific time limit to do so. However, for our Posts application, we will show how to delete a post without any restrictions.

The DELETE endpoint allows users to remove a post from the system. Here's how you can implement this functionality in your NestJS application.

1. Update Posts Service

First, update the PostsService to include a method for deleting a post. In posts.service.ts, add:

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

The remove method deletes the post and returns the deleted post.

2. Update Posts Controller

Now, update the PostsController to handle the DELETE request. In posts.controller.ts, add:

@Delete(':id')
async remove(
  @Param('id') id: string,
): Promise<{ statusCode: number; message: string }> {
  const post = await this.postsService.remove(id);
  if (!post) {
    throw new NotFoundException(`Post with ID ${id} not found`);
  }
  return {
    statusCode: 200,
    message: 'Post deleted successfully',
  };
}

This action invokes the remove method of the PostsService. It uses the @Param decorator to extract the id from the URL parameters.

The function returns NotFoundException if the post does not exist. If the deletion is successful, a message indicating success is returned to the client.

3. Testing with Postman

You can test the DELETE endpoint using Postman:

  • Set up a DELETE request to your server's /posts/{id} endpoint, where {id} is the ID of the post you want to delete.
  • Send the request and confirm that the post is deleted. The response should be a 200 OK status with no content.

After testing the endpoint and confirming it works as expected, commit your changes:

git add .
git commit -m "Implement DELETE endpoint for posts"

Step 7: Implement GET (all) endpoint

This step involves creating an endpoint that allows the retrieval of all posts from the database. This is a common feature in social media platforms where users can scroll through a feed of posts.

1. Update Posts Service

First, update the PostsService to include a method for retrieving all posts. In posts.service.ts, add the following findAll method:

async findAll(): Promise<Post[]> {
  return this.postRepository.find();
}

Here, the findAll method uses the repository's find function to retrieve all posts.

2. Update Posts Controller

Now, update the PostsController to handle the GET request for all posts. In posts.controller.ts, add the following action:

@Get()
async findAll(): Promise<PostResponseDto[]> {
  const posts = await this.postsService.findAll();
  return posts.map((post) => {
    delete post.userId;
    return post;
  });
}

This action retrieves all posts using the findAll method of the PostsService. It then maps over each post to format the response.

3. Testing with Postman

To ensure your GET endpoint is working as expected, you can test it using Postman:

  • Set up a new GET request to your server's /posts endpoint.
  • Send the request and confirm that you receive an array of post objects, each including the respective author's data.

If the endpoint is functioning correctly, you'll get a list of posts (with the author’s userId excluded).

After testing the functionality, remember to commit your changes to version control:

git add .
git commit -m "Implement GET endpoint for retrieving all posts"