[ PART 13 ] Creating a Twitter clone with GraphQL, Typescript, and React ( followers )

ipscodingchallenge

ips-coding-challenge

Posted on January 16, 2021

[ PART 13 ] Creating a Twitter clone with GraphQL, Typescript, and React ( followers )

Hi everyone ;).

As a reminder, I'm doing this Tweeter challenge

Github repository

Db diagram

Add the followers Table

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

src/db/migrations/create_followers_table

import * as Knex from 'knex'

export async function up(knex: Knex): Promise<void> {
  return knex.schema.createTable('followers', (t) => {
    t.increments('id')
    t.integer('follower_id').notNullable()
    t.integer('following_id').notNullable()

    t.foreign('follower_id')
      .references('id')
      .inTable('users')
      .onDelete('CASCADE')
    t.foreign('following_id')
      .references('id')
      .inTable('users')
      .onDelete('CASCADE')
  })
}

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

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

I will then create a Follower Resolver:

src/resolvers/FollowerResolver.ts

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

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

    try {
      const userToFollow = await db('users').where('id', following_id)

      if (!userToFollow) {
        throw new ApolloError('User not found')
      }

      const [alreadyFollow] = await db('followers').where({
        follower_id: userId,
        following_id,
      })

      // Delete the follow
      if (alreadyFollow) {
        await db('followers')
          .where({
            follower_id: userId,
            following_id,
          })
          .del()

        return 'You are no longer following this user'
      }

      await db('followers').insert({
        follower_id: userId,
        following_id,
      })

      return 'User followed!'
    } catch (e) {
      console.log('e', e)
      throw e
    }
  }
}

export default FollowerResolver

Enter fullscreen mode Exit fullscreen mode

We add the resolver to the server:

src/server.ts

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

Here are some tests that I wrote too:

src/tests/followers.test.ts

import db from '../db/connection'
import { generateToken } from '../utils/utils'
import { createUser, followUser } from './helpers'
import { testClient } from './setup'
import { TOGGLE_FOLLOW } from './queries/followers.queries'

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

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

  it('should add a user to follow', async () => {
    const user = await createUser()
    const userToFollow = await createUser('new', 'new@test.fr')

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

    const res = await mutate({
      mutation: TOGGLE_FOLLOW,
      variables: {
        following_id: userToFollow.id,
      },
    })

    const [follower] = await db('followers').where({
      follower_id: user.id,
      following_id: userToFollow.id,
    })

    expect(follower).not.toBeUndefined()

    expect(res.data.toggleFollow).toEqual('User followed!')
  })

  it('should delete a user that a user follow', async () => {
    const user = await createUser()
    const userToFollow = await createUser('new', 'new@test.fr')
    await followUser(user, userToFollow)

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

    const res = await mutate({
      mutation: TOGGLE_FOLLOW,
      variables: {
        following_id: userToFollow.id,
      },
    })

    const [follower] = await db('followers').where({
      follower_id: user.id,
      following_id: userToFollow.id,
    })

    expect(follower).toBeUndefined()

    expect(res.data.toggleFollow).toEqual(
      'You are no longer following this user'
    )
  })
})

Enter fullscreen mode Exit fullscreen mode

And that's pretty much it. For now, I don't have any queries for the followers because I don't know yet how I will handle that in the Frontend.

Now that we can have users that we follow, let's modify our feed query.

src/resolvers/TweetResolver.ts

@Query(() => [Tweet])
  @Authorized()
  async feed(@Ctx() ctx: MyContext) {
    const { db, userId } = ctx

    const followedUsers = await db('followers')
      .where({
        follower_id: userId,
      })
      .pluck('following_id')

    const tweets = await db('tweets')
      .whereIn('user_id', followedUsers)
      .orWhere('user_id', userId)
      .orderBy('id', 'desc')
      .limit(20)

    return tweets
  }
Enter fullscreen mode Exit fullscreen mode

As I previously said, I will only allow connected users to access the application. Therefore, I add the @Authorized() annotation to the query. Then I just get the users followed by the connected user and I fetch the tweets from those users and from the connected user. I also fix the tests by adding the authorization header when needed.

I noticed too that I should have two more properties on the User according to the challenge details ;). I need the Bio and also a "banner image". Let's do that:

knex migrate:make add_bio_banner_users -x ts
Enter fullscreen mode Exit fullscreen mode
import * as Knex from 'knex'

export async function up(knex: Knex): Promise<void> {
  return knex.schema.alterTable('users', (t) => {
    t.string('bio').nullable()
    t.string('banner').nullable()
  })
}

export async function down(knex: Knex): Promise<void> {
  return knex.schema.alterTable('users', (t) => {
    t.dropColumn('bio')
    t.dropColumn('banner')
  })
}

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

Add them to the User entity

  @Field({ nullable: true })
  bio?: string

  @Field({ nullable: true })
  banner?: string
Enter fullscreen mode Exit fullscreen mode

Also, I expose the user's email to everyone. We will need to change that later as we only need to show the user's email on its own profile. Let's just add an issue to not forget that :D.

I will stop here for today ;).

Bye and take care ;)!

💖 💪 🙅 🚩
ipscodingchallenge
ips-coding-challenge

Posted on January 16, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related