DEV Community

Cover image for [ PART 8 ] Creating a Twitter clone with GraphQL, Typescript, and React ( Add likes to tweet )
ips-coding-challenge
ips-coding-challenge

Posted on

[ PART 8 ] Creating a Twitter clone with GraphQL, Typescript, and React ( Add likes to tweet )

Hi everyone ;). I hope you are well.

As a reminder, I try to do this challenge: Tweeter challenge

Github repository

Db diagram

Today I propose you to add the possibility to like a tweet.

knex migrate:make create_likes_table -x ts
Enter fullscreen mode Exit fullscreen mode

src/db/migrations/create_likes_table

import * as Knex from 'knex'

export async function up(knex: Knex): Promise<void> {
  return knex.schema.createTable('likes', (t) => {
    t.increments('id')
    t.integer('user_id').unsigned().notNullable()
    t.integer('tweet_id').unsigned().notNullable()

    t.unique(['user_id', 'tweet_id'])

    t.foreign('user_id').references('id').inTable('users').onDelete('CASCADE')
    t.foreign('tweet_id').references('id').inTable('tweets').onDelete('CASCADE')
  })
}

export async function down(knex: Knex): Promise<void> {
  return knex.raw('DROP TABLE likes CASCADE')
}

Enter fullscreen mode Exit fullscreen mode
knex migrate:latest
Enter fullscreen mode Exit fullscreen mode

I add a unique constraint concerning the columns user_id and tweet_id because you can only like a tweet once ;).

src/resolvers/LikeResolver.ts

import { ApolloError } from 'apollo-server'
import {
  Arg,
  Authorized,
  Ctx,
  Int,
  Mutation,
  ObjectType,
  Resolver,
} from 'type-graphql'
import { MyContext } from '../types/types'

@Resolver()
class LikeResolver {
  @Mutation(() => String)
  @Authorized()
  async toggleLike(@Arg('tweet_id') tweet_id: number, @Ctx() ctx: MyContext) {
    const { db, userId } = ctx

    const [tweet] = await db('tweets').where('id', tweet_id)

    if (!tweet) {
      throw new ApolloError('Tweet not found')
    }

    const data = {
      user_id: userId,
      tweet_id: tweet_id,
    }

    try {
      const [alreadyLiked] = await db('likes').where(data)

      if (alreadyLiked) {
        // Delete the like and return
        await db('likes').where(data).del()
        return 'Like deleted'
      }

      await db('likes').insert(data)

      return 'Like added'
    } catch (e) {
      throw new ApolloError(e.message)
    }
  }
}
export default LikeResolver

Enter fullscreen mode Exit fullscreen mode

Note that I create a single method to manage the adding or removal of a "like". So, I check if I already have a "like" and if so, I delete it. Otherwise, I add it.

I need to also add the LikeResolver to my schema method:

src/server.ts

...
export const schema = async () => {
  return await buildSchema({
    resolvers: [AuthResolver, TweetResolver, LikeResolver],
    authChecker: authChecker,
  })
}
Enter fullscreen mode Exit fullscreen mode

If I launch my server, everything works properly:
Like Mutation Result

Let's write some tests:

src/tests/likes.test.ts

import db from '../db/connection'
import { generateToken } from '../utils/utils'
import { createLike, createTweet, createUser } from './helpers'
import { TOGGLE_LIKE } from './queries/likes.queries'
import { ADD_TWEET, FEED, DELETE_TWEET } from './queries/tweets.queries'
import { testClient } from './setup'

describe('Likes', () => {
  beforeEach(async () => {
    await db.migrate.rollback()
    await db.migrate.latest()
  })

  afterEach(async () => {
    await db.migrate.rollback()
  })

  it('should add a like', async () => {
    const user = await createUser()
    const tweet = await createTweet(user)

    const { mutate } = await testClient({
      req: {
        headers: {
          authorization: 'Bearer ' + generateToken(user),
        },
      },
    })

    const res = await mutate({
      mutation: TOGGLE_LIKE,
      variables: {
        tweet_id: tweet.id,
      },
    })

    const [like] = await db('likes').where({
      user_id: user.id,
      tweet_id: tweet.id,
    })

    expect(like).not.toBeUndefined()

    expect(res.data.toggleLike).toEqual('Like added')
    expect(res.errors).toBeUndefined()
  })

  it('should add delete a like', async () => {
    const user = await createUser()
    const tweet = await createTweet(user)
    await createLike(user, tweet)

    const { mutate } = await testClient({
      req: {
        headers: {
          authorization: 'Bearer ' + generateToken(user),
        },
      },
    })

    const res = await mutate({
      mutation: TOGGLE_LIKE,
      variables: {
        tweet_id: tweet.id,
      },
    })

    const [deleted] = await db('likes').where({
      user_id: user.id,
      tweet_id: tweet.id,
    })

    expect(deleted).toBeUndefined()

    expect(res.data.toggleLike).toEqual('Like deleted')
    expect(res.errors).toBeUndefined()
  })

  it('should not authorized an anonymous user to like a tweet', async () => {
    const user = await createUser()
    const tweet = await createTweet(user)

    const { mutate } = await testClient()

    const res = await mutate({
      mutation: TOGGLE_LIKE,
      variables: {
        tweet_id: tweet.id,
      },
    })

    const likes = await db('likes')
    expect(likes.length).toEqual(0)

    expect(res.errors![0].message).toEqual('Unauthorized')
  })

  it('should not authorized an anonymous user to delete a like', async () => {
    const user = await createUser()
    const tweet = await createTweet(user)
    const like = await createLike(user, tweet)

    const { mutate } = await testClient()

    const res = await mutate({
      mutation: TOGGLE_LIKE,
      variables: {
        tweet_id: tweet.id,
      },
    })

    const likes = await db('likes')
    expect(likes.length).toEqual(1)

    expect(res.errors![0].message).toEqual('Unauthorized')
  })
})

Enter fullscreen mode Exit fullscreen mode

Like tests results

Before finishing, it might be a good idea to try to get the number of likes for a tweet? I modified the seed file to add some random likes. I'll let you go check it out in the Github Repository ;).

src/entities/Tweet.ts

@Field()
likesCount: number
Enter fullscreen mode Exit fullscreen mode

We will need a "dataloader" to avoid the n+1 problem when we add the @FieldResolver for likes.

src/dataloaders/dataloaders.ts

import DataLoader from 'dataloader'
import db from '../db/connection'
import User from '../entities/User'

export const dataloaders = {
  userDataloader: new DataLoader<number, User, unknown>(async (ids) => {
    const users = await db('users').whereIn('id', ids)

    return ids.map((id) => users.find((u) => u.id === id))
  }),
  // Get the likesCount for each tweet
  likesCountDataloader: new DataLoader<number, any, unknown>(async (ids) => {
    const counts = await db('likes')
      .whereIn('tweet_id', ids)
      .count('tweet_id', { as: 'likesCount' })
      .select('tweet_id')
      .groupBy('tweet_id')

    return ids.map((id) => counts.find((c) => c.tweet_id === id))
  }),
}

Enter fullscreen mode Exit fullscreen mode

We can now add a @FieldResolver in our TweetResolver:

src/resolvers/TweetResolver.ts

 @FieldResolver(() => Int)
  async likesCount(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
    const {
      dataloaders: { likesCountDataloader },
    } = ctx
    const count = await likesCountDataloader.load(tweet.id)
    return count?.likesCount || 0
  }
Enter fullscreen mode Exit fullscreen mode

Alt Text

I do have three SQL queries that are made. One to retrieve the tweets. Another one to retrieve the associated user and the last one to retrieve the number of likes.

However, you will notice a problem. If you run the toggleLike method several times and try to refresh the feed method you will see that the likesCount property will not update. To avoid this problem, we will have to clear the cache when we want to add or remove a "like".

Clear the dataloader cache

src/resolvers/LikeResolver.ts

@Mutation(() => String)
  @Authorized()
  async toggleLike(@Arg('tweet_id') tweet_id: number, @Ctx() ctx: MyContext) {
    const {
      db,
      userId,
      dataloaders: { likesCountDataloader }, // I get the dataloaders from the context
    } = ctx

    const [tweet] = await db('tweets').where('id', tweet_id)

    if (!tweet) {
      throw new ApolloError('Tweet not found')
    }

    const data = {
      user_id: userId,
      tweet_id: tweet_id,
    }

    try {
      const [alreadyLiked] = await db('likes').where(data)

      if (alreadyLiked) {
        // Delete the like and return
        await db('likes').where(data).del()

        likesCountDataloader.clear(tweet_id) // I clear the dataloader for this particular tweet_id

        return 'Like deleted'
      }

      await db('likes').insert(data)

      likesCountDataloader.clear(tweet_id) // I clear the dataloader for this particular tweet_id

      return 'Like added'
    } catch (e) {
      throw new ApolloError(e.message)
    }
  }
Enter fullscreen mode Exit fullscreen mode

It should now work as expected.

It will be all for today ;).

Bye and take care! ;)

Top comments (0)