[ PART 8 ] Creating a Twitter clone with GraphQL, Typescript, and React ( Add likes to tweet )
Tags: showdev, javascript, career, node
Published: 2021-01-11 16:00:13

Hi everyone ;). I hope you are well.

As a reminder, I try to do this challenge: [Tweeter challenge](https://devchallenges.io/challenges/rleoQc34THclWx1cFFKH)

[Github repository](https://github.com/ips-coding-challenge/challenge_twitter)

[Db diagram](https://dbdiagram.io/d/5f75c0243a78976d7b75f7be)

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

```sh
knex migrate:make create_likes_table -x ts
```

*src/db/migrations/create_likes_table*

```typescript
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')
}

```

```sh
knex migrate:latest
```

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*

```typescript
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

```

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*

```typescript
...
export const schema = async () => {
  return await buildSchema({
    resolvers: [AuthResolver, TweetResolver, LikeResolver],
    authChecker: authChecker,
  })
}
```
If I launch my server, everything works properly:
![Like Mutation Result](https://dev-to-uploads.s3.amazonaws.com/i/bmu95epvqisk4ramx5sm.png)

Let's write some tests:

*src/tests/likes.test.ts*

```typescript
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')
  })
})

```
![Like tests results](https://dev-to-uploads.s3.amazonaws.com/i/k4qqdbk4ldhlmo3nf8sd.png)

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](https://github.com/ips-coding-challenge/challenge_twitter) ;).

*src/entities/Tweet.ts*

```typescript
@Field()
likesCount: number
```

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

*src/dataloaders/dataloaders.ts*

```typescript
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))
  }),
}

```

We can now add a @FieldResolver in our TweetResolver:

*src/resolvers/TweetResolver.ts*

```typescript
 @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
  }
```
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/17s6dupjvqmb2vjo2nw6.png)

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*

```typescript
@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)
    }
  }
```

It should now work as expected.

It will be all for today ;).


Bye and take care! ;)