https://a.storyblok.com/f/270183/132816/6ed4e4c45e/nextjs_prisma-2_graphql_sms-1200x600.png

Criação de um aplicativo de bate-papo em tempo real com GraphQL e notificações por SMS

Publicado em February 25, 2021

Tempo de leitura: 15 minutos

Com o surgimento do GraphQL surgiu uma nova maneira de os desenvolvedores criarem aplicativos cliente/servidor. Os benefícios de desenvolver aplicativos GraphQL são inúmeros, desde solicitar explicitamente o que você precisa do servidor até a comunicação em tempo real orientada a eventos por meio de assinaturas. Este artigo destaca o GraphQL “code-first” e seus superpoderes. O artigo também explicará como desenvolver um aplicativo de bate-papo com tecnologia Next.js e Apollo no front-end, e Prisma 2, GraphQL-yoga e notificações por SMS usando a excelente SMS API da Vonage no back-end.

GraphQL com abordagem “Code-First”

O GraphQL “code-first” é uma abordagem para o desenvolvimento de servidores GraphQL na qual você escreve seus resolvers e delega a geração do esquema por meio de programação. Ela é frequentemente chamada de abordagem “resolver first”. A geração do esquema é feita por uma ferramenta que percorre seus resolvedores e gera o esquema. A abordagem “schema first” é o oposto da abordagem “code-first” e envolve a definição dos tipos, respostas etc. do seu servidor.

Desenvolvimento de back-end

Vamos desenvolver um aplicativo de bate-papo em tempo real com notificações por SMS.

Para começar, clone este repositório. Ele contém a configuração básica necessária para acompanhar este artigo.

Pré-requisitos

  • Node.js >= 10.0.0

  • Conhecimentos anteriores sobre o Prisma

  • Compreensão do GraphQL

  • Um banco de dados, por exemplo, o MySQL

  • Prisma CLI

  • Um Account da Vonage

Agora, vamos entender a estrutura do diretório do projeto.

Existem duas pastas no diretório raiz. O diretório backend contém uma pasta chamada Prisma, que abriga a configuração do Prisma. Há um arquivo schema.prisma na pasta Prisma que inclui a configuração do banco de dados e um banco de dados SQLite chamado dev.db. Navegue até o diretório backend e execute npm install para instalar todas as dependências necessárias.

Além disso, crie um arquivo .env no diretório backend; ele conterá as variáveis de ambiente necessárias, como a URL do banco de dados e outras variáveis relacionadas. Para a URL do banco de dados, cole o seguinte no arquivo .env:

DATABASE_URL="file:./dev.db"

O diretório `pages` na pasta `frontend` é onde o Next.js servirá as páginas do aplicativo. O diretório `pages` contém um arquivo `_app.js` que foi configurado para funcionar com o Bootstrap. Navegue até o diretório `frontend` e execute npm install. Essa pasta também inclui um diretório “src” com os subdiretórios “assets”, “components” e “utils”.

Em seguida, acesse o arquivo prisma/schema.prisma. Precisamos de dois modelos: um para User e outro para Chat. Abaixo está a configuração do cliente do gerador:

model  User {

id Int  @id  @default(autoincrement())
name String
email String?  @unique
password String
phone String  @unique
isAdmin Boolean  @default(false)
messages Chat[]
createdAt DateTime  @default(now())
updatedAt DateTime  @default(now())
Chat Chat[]  @relation("RecieverOfChat")
}

model  Chat {
id Int  @id  @default(autoincrement())
receiverId Int
receiver User  @relation("RecieverOfChat", fields: [receiverId], references: [id])
sender User  @relation(fields: [senderId], references: [id])
senderId Int
message String
createdAt DateTime  @default(now())
updatedAt DateTime  @default(now())
}

O modelo representa o nome da tabela que será criada no banco de dados, enquanto os campos representam os nomes das colunas e os tipos de dados que serão armazenados nelas. Além dos tipos de dados disponíveis no Prisma, um modelo também pode ser um tipo de dados. É isso que define a relação entre dois ou mais modelos ou uma autorrelação para um modelo. Anotamos cada modelo com palavras-chave do Prisma. Caso não compreenda as palavras-chave utilizadas, consulte a documentação.

Execute prisma migrate save --experimental. Dê um nome à sua migração e execute prisma migrate up --experimental. O comando criará as tabelas com base nas definições do modelo no esquema. Por fim, execute prisma generate para expor o esquema do banco de dados mapeado aos métodos e recursos do Prisma que habilitam as funcionalidades CRUD.

Acesse o diretório src/types e crie os arquivos User e Chat. Já existem quatro arquivos: Mutation.js, Query.js, Subscription.js e um arquivo index.js que reúne todos os resolvedores em um único arquivo.

No arquivo User.js, adicione:

const { objectType } = require("@nexus/schema");

const User = objectType({
  name: "User",
  definition(t) {
    t.model.id();
    t.model.name();
    t.model.email();
    t.model.phone();
    t.model.isAdmin();
    t.model.messages();
    t.model.createdAt();
    t.model.updatedAt();
  },
});

module.exports = {
  User,
};

No arquivo Chat.js, adicione:

const { objectType } = require("@nexus/schema");

const Chat = objectType({
  name: "Chat",
  definition(t) {
    t.model.id();
    t.model.receiver();
    t.model.sender();
    t.model.message();
    t.model.createdAt();
    t.model.updatedAt();
  },
});

module.exports = {
  Chat,
};

Importamos um objectType do Nexus, pois os modelos User e Chat são do tipo object. Acessamos os campos que definimos em nosso esquema usando o método model e o nome do campo. Isso é possível graças ao nexus-plugin-prisma que instalamos. Isso nos ajuda a evitar a necessidade de definir cada campo um por um e configurá-lo. O código abaixo é um exemplo de como fazer a configuração manualmente:

t.id("id", { description: "The ID (Primary Key) of the table or model" });
   t.string("name");
   t.boolean("isAdmin");
   // ... the rest

No arquivo de mutação, vamos lidar com o login e o cadastro. Crie um novo arquivo no diretório src/types chamado AuthPayload.js. Trata-se de um tipo de objeto que representa o tipo de payload de autenticação que seria retornado ao cliente.

No arquivo AuthPayload.js, adicione:

const { objectType } = require("@nexus/schema'");
 
const AuthPayload = objectType({
 name: "AuthPayload",
 definition(t) {
   t.string("token");
   t.field("user", { type: "User" });
 },
});
 
module.exports = { AuthPayload };

No diretório src/utils, crie um arquivo helper.js e defina esses métodos.

const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");

const getUserId = async (ctx) => {
  let Authorization = ctx.request
    ? ctx.request.get("Authorization")
    : ctx.connection.context.Authorization;

  if (Authorization === undefined && ctx.connection) {
    Authorization = ctx.connection.context.headers.Authorization;
  } else if (Authorization === undefined && ctx.request.cookies) {
    Authorization = ctx.request.cookies.token;
  } else {
    // it means no authorization header was sent
  }

  if (Authorization) {
    const token = Authorization.replace("Bearer", "");
    return token.length === 0
      ? null
      : jwt.verify(token.trim(), process.env.JWT_SECRET).userId;
  }
  return null;
};

const genToken = userId => {
  return jwt.sign({ userId }, process.env.JWT_SECRET, {
    expiresIn: "364 days",
  });
};

const hashPassword = password => {
  if (password.length < 6) {
    throw new Error("Password must be 8 characters or longer");
  }

  return bcrypt.hash(password, Number(process.env.SALTROUND));
};

module.exports = {
  getUserId,
  genToken,
  hashPassword,
};

NOTA: A esta altura, você já deve ter definido as variáveis de ambiente no seu arquivo .env para esses métodos.

Agora, temos uma maneira de obter o ID do usuário usando o método getUser. Vamos modificar o arquivo script.js. Importe o método getUser de src/utils/helpers e remova o comentário desta parte do método context no arquivo de configuração do servidor.

// const userId = getUserId(sConfig);
    // const user = await prisma.user.findOne({ where: { id: Number(userId) } });
    // if (user) {
    //   data.user = user;
    // }

No arquivo mutation.js, adicione o código para o cadastro:

const { idArg, mutationType, stringArg, booleanArg } = require("@nexus/schema");
const { compare } = require("bcryptjs");
const { genToken, hashPassword } = require("../utils/helpers");
 
 
const Mutation = mutationType({
 definition(t) {
   t.field("signup", {
     type: "AuthPayload",
     args: {
       name: stringArg({nullable:false}),
       email: stringArg({ nullable: true }),
       password: stringArg({nullable:false}),
       phone: stringArg({nullable:false}),
       isAdmin: booleanArg({ nullable: true, default: false }),
     },
     resolve: async (parent, args, ctx) => {
       const emailAddress = args.email ? args.email.toLowerCase() :""
 
       const existingUser = await ctx.prisma.user.findFirst({
         where: {
           OR: [
             {
               email: emailAddress,
             },
             {
               phone: args.phone,
             },
           ],
         },
       });
 
       if (existingUser) {
         if (existingUser.email.toLowerCase() === emailAddress) {
           throw new Error("A user with this email address currently exist");
         } else {
           throw new Error("A user with this phone number currently exist");
         }
       }
 
       const hashedPassword = await hashPassword(args.password);
       let token;
 
       let user = await ctx.prisma.user.create({
         data: {
           ...args,
           password: hashedPassword,
           email: emailAddress,
         },
       });
 
       token = genToken(user.id);
	 ctx.sConfig.response.cookie("token", token, {
         maxAge: 1000 * 60 * 60 * 24 * 365,
         path: "/",
         sameSite: "none",
         secure: true,
       });
 
       return {
         token,
         user,
       };
     },
   });  
},
});

Primeiro, verificamos se o banco de dados contém um usuário com esse e-mail ou número de telefone; se o usuário já existir, geramos um erro; caso contrário, criamos o usuário, geramos um token usando o ID do usuário e definimos o token como um cookie. Também retornamos a carga útil de cadastro.

Adicione o seguinte ao arquivo login.js, logo abaixo do resolvedor de cadastro:

t.field("login", {
   type: "AuthPayload",
      args: {
        username: stringArg({ nullable: false }),
        password: stringArg(),
      },
      resolve: async (parent, { username, password }, ctx) => {
        
        const user = await ctx.prisma.user.findFirst({
          where: {
            OR: [
              {
                email: username,
              },
              {
                phone: username,
              },
            ],
          },
        });
 
     if (!user) {
       throw new Error("Invalid login credentials provided");
     }
     const passwordValid = await compare(password, user.password);
     if (!passwordValid) {
       throw new Error("Invalid password");
     }
     let token;
 
     token = genToken(user.id);
 
	 ctx.sConfig.response.cookie("token", token, {
         maxAge: 1000 * 60 * 60 * 24 * 365,
         path: "/",
         sameSite: "none",
         secure: true,
       });
	
		const textMessage = `Hi ${user.name}. Just confirming that this is you. If it's not, please reset your password immediately. `;

        await ctx.vonage.message.sendSms(
          process.env.ADMIN_PHONE,
          user.phone,
          textMessage,
          {
            type: "unicode",
          },
          (err, response) => {
            if (err) {
              console.log(err);
            } else {
              console.log(response, "eerr");
              if (response.messages[0]["status"] === "0") {
                console.log("Message sent successfully.");
              } else {
                console.log(
                  `Message failed with error: ${response.messages[0]["error-text"]}`
                );
              }
            }
          }
        );
 
     return {
       token,
       user,
     };
   },
 });

Verificamos se o usuário existe, validamos suas credenciais de login, definimos o token como um cookie e retornamos o tipo de mutação da carga de autenticação. Também enviamos a ele uma notificação por SMS por meio da instância da SMS API da Vonage que criamos na configuração do servidor.

Para gerenciar as notificações por SMS aos usuários no aplicativo, comprei um número virtual da Vonage. Você deve seguir este artigo para começar a criar um aplicativo de SMS da Vonage. Depois de criar o aplicativo, um arquivo de chave privada será baixado automaticamente para o seu computador. Mova esse arquivo para o diretório de backend. Você também deve definir uma variável de ambiente chamada ADMIN_PHONE no seu arquivo .env, com o número virtual que comprei na conta da Vonage.

Acesse o arquivo types/index.js e comente as importações de Query e Subscription, já que não temos nada ali. Por enquanto, importe seus arquivos User.js e Chat.js.

Vamos adicionar um resolvedor de consulta para permitir que o usuário consulte os detalhes da sua Account. No arquivo Query.js, adicione:

const  Query  =  queryType({
definition(t) {
    t.field("myAccount", {
      type: "User",
      resolve: async (parent, args, ctx) => {
        const myAccount = ctx.user;

        if (!myAccount) {
          throw new Error(
            "You are not logged in. Please login to view your account information"
          );
        }

        return myAccount;
      },
    });
   }
})

Como já tratamos da consulta do usuário em cada solicitação que recebemos no campo “context” da configuração do servidor, podemos acessar o usuário caso ele exista no objeto ctx. Se o usuário não existir no objeto ctx, isso significa que ele precisa fazer login. No arquivo types/index.js, remova o comentário do arquivo Query.js importado.

Vamos criar mais dois resolvedores de consulta: um para consultar um usuário e outro para consultar vários usuários. Usaremos as funcionalidades CRUD do nexus-plugin-prisma; como se trata de um recurso experimental, precisamos ativá-lo. No arquivo arquivo script.js , no campo plugins, adicione {experimentalCRUD: true} à função nexusPrisma, caso ainda não esteja lá.

No Query.js, adicione:

t.crud.user();
   t.crud.users({
     ordering: true,
     filtering: true,
     pagination: true,
   });

Para usar essa funcionalidade, certifique-se de ter nomeado seus modelos de esquema no singular, como User e não Users. Vamos também usar esse recurso para lidar com os resolvedores de atualização e exclusão de um usuário no arquivo de mutação.

t.crud.updateOneUser({
     alias:"updateUser"
   })
 
   t.crud.deleteOneUser({
     alias:"deleteUser"
   })

Vamos lidar agora com os resolvedores do Chat. Vamos também adicionar mais dois arquivos: um chamado Subscription.js e outro chamado SubscriptionPayload. Adicione ambos os arquivos à sua lista de tipos (resolvedores) no arquivo index.js.

SubscriptionPayload.js

const { objectType } = require("@nexus/schema");
 
const SubscriptionPayload = objectType({
 name: "SubscriptionPayload",
 definition(t) {
   t.field("message", { type: "Chat" });
   t.field("mutation", { type: "String" });
 }
});
 
module.exports = { SubscriptionPayload };

Para gerenciar as assinaturas, usaríamos o método PubSub , que vem com o pacote Graphql-yoga.

Primeiro, vamos criar funcionalidades CRUD para um chat, no qual o resolvedor de assinaturas ficaria atento a esses eventos CRUD. Vamos também criar uma função `sendNewMessageNotification` para lidar com o envio de notificações aos destinatários do chat sempre que as conversas anteriores entre eles tiverem ocorrido há menos de uma hora.

No arquivo helper.js, adicione:

const sendNewMessageNotification = async (lastMessage, vonage) => {
  const oneHour = 60 * 60 * 1000;
  const messageTime = new Date(lastMessage.createdAt);

  const anHourAgo = Date.now() - oneHour;

  if (messageTime.getTime() < anHourAgo) {
    const textMessage = `Hi ${lastMessage.receiver.name}. You have a new message from ${lastMessage.sender.name}. Login to your account to continue chatting with them.`;
    
    await vonage.message.sendSms(
      "AWESOME CHAT APP",
      lastMessage.receiver.phone,
      textMessage,
      {
        type: "unicode",
      },
      (err, response) => {
        if (err) {
          console.log(err);
        } else {
          console.log(response, "eerr");
          if (response.messages[0]["status"] === "0") {
            console.log("Message sent successfully.");
          } else {
            console.log(
              `Message failed with error: ${response.messages[0]["error-text"]}`
            );
          }
        }
      }
    );
  }
};

Este método verifica se a última mensagem enviada foi há menos de 1 hora. Se for o caso, enviamos uma notificação por SMS ao destinatário da mensagem; caso contrário, não fazemos nada. Exporte o método `sendNewMessageNotification` e importe-o no arquivo Mutation.js. Vamos agora lidar com o resolver `createChat`.

No Mutation.js, adicione:

t.field("createChat", {
      type: "Chat",
      args: {
        receiverId: intArg({ nullable: false }),
        message: stringArg({ nullable: false }),
      },
      resolve: async (parent, { receiverId, message }, ctx) => {
        const sender = ctx.user;

        if (!sender) {
          throw new Error(errorMessage);
        }

        if (message.length === 0) {
          throw new Error("Your message must not be empty");
        }

        const lastsentMessage = await ctx.prisma.chat.findFirst({
          orderBy: [
            {
              createdAt: "desc",
            },
          ],
          where: {
            OR: [
              {
                OR: [
                  {
                    senderId: sender.id,
                  },
                  {
                    receiverId,
                  },
                ],
              },
              {
                OR: [
                  {
                    senderId: receiverId,
                  },
                  {
                    receiverId: sender.id,
                  },
                ],
              },
            ],
          },
          include: {
            sender: true,
            receiver: true,
          },
        });
	    const newMessage = await ctx.prisma.chat.create({
          data: {
            message,
            receiver: {
              connect: {
                id: receiverId,
              },
            },
            sender: {
              connect: {
                id: sender.id,
              },
            },
          },
        });

        if (lastsentMessage) {
          await sendNewMessageNotification(lastsentMessage, ctx.vonage);
        }
        await ctx.pubSub.publish("CREATED", {
          Chat: {
            message: newMessage,
            mutation: "CREATED",
          },
          senderId: sender.id,
          receiverId,
        });

        return newMessage;
      },
    });

Verificamos se o remetente da mensagem está conectado, conferindo se o objeto do usuário existe no servidor; em seguida, também verificamos se a mensagem não está vazia. Primeiro, consultamos a última mensagem enviada ou recebida pelo usuário e, em seguida, criamos a nova mensagem. Se a mensagem anterior entre eles tiver sido enviada há menos de uma hora, o método `sendNewMessageNotification` é acionado. Por fim, vamos tratar da questão da assinatura.

No arquivo Subscription.js, adicione:

const { intArg, subscriptionField } = require("@nexus/schema");

const { withFilter } = require("graphql-yoga");

const mutationType = ["CREATED", "UPDATED", "DELETED"];

const Subscription = subscriptionField("Chat", {
  type: "SubscriptionPayload",
  args: {
    receiverId: intArg({
      nullable: false,
    }),
  },
  description: "Subscription For Chats",
  subscribe: withFilter(
    (parent, args, ctx) => {
      const sender = ctx.user;

      if (!sender) {
        throw new Error("You are not logged in. Please login to your account.");
      }

      args.senderId = sender.id;
      return ctx.pubSub.asyncIterator(mutationType);
    },
    (payload, variables) => {
      if (
        (Number(payload.senderId) === Number(variables.senderId) &&
          Number(payload.receiverId) === Number(variables.receiverId)) ||
        (Number(payload.senderId) === Number(variables.receiverId) &&
          Number(payload.receiverId) === Number(variables.senderId))
      ) {
        return true;
      }

      return false;
    }
  ),
  resolve: async (payload) => {
    const { Chat } = await payload;

    return Chat;
  },
});

module.exports = {
  Subscription,
};

Importamos os objetos `intArg` e `subScriptionFIeld` do `nexus/schema` e também importamos o método `withFilter` do pacote `graphql-yoga` — o que nos ajuda a garantir que apenas os usuários corretos recebam cargas úteis ou eventos. O primeiro argumento é o resolvedor `subscribe`, que retorna o `asyncIterator` que queremos filtrar, e a ele são passados os eventos que queremos monitorar, ou seja, CREATED, UPDATED, DELETED. O segundo argumento é a condição que deve ser atendida para que esse evento seja transmitido. Para o nosso caso de uso, esse evento deve ser transmitido apenas para o remetente e o destinatário dos dados da mensagem. Por curiosidade, comente esse campo e teste. Você deve perceber que o envio de uma mensagem notifica todos os usuários no aplicativo que estão escutando o resolvedor `createChat`.

Agora, vamos adicionar as mutações `updateChat` e `deleteChat` — que são um pouco diferentes da criação de um chat. Primeiro, precisamos verificar se o usuário está autenticado. Em segundo lugar, precisamos verificar se a mensagem existe; por fim, precisamos verificar se é o remetente da mensagem quem pode atualizá-la ou excluí-la. Um usuário que não enviou a mensagem não deve ter acesso para excluí-la. Se essas condições forem atendidas, atualizamos ou excluímos o chat e, em seguida, notificamos nossos assinantes.

Para a mutação `updateChat`, adicione:

t.field("updateChat", {
     type: "Chat",
     args: {
       messageId: intArg({ nullable: false }),
       message: stringArg({ nullable: false }),
     },
     resolve: async (parent, { messageId, message }, ctx) => {
       const sender = ctx.user;
 
       if (!sender) {
         throw new Error(
           "You are not logged in. Please login to your account."
         );
       }
 
       const sentMessage = await ctx.prisma.chat.findOne({
         where: {
           id: Number(messageId)
         }
       });
       if (!sentMessage) {
         throw new Error("This message does not exist.");
       }
 
       if (Number(sentMessage.senderId) !== Number(sender.id)) {
         throw new Error("You don't have the permission to delete this message. You can only delete messages created by you.")
       }
 
       const updatedMessage = await ctx.prisma.chat.update({
         where: {
           id: sentMessage.id
         },
         data: {
           message
         }
       });
 
       await ctx.pubSub.publish("UPDATED", {
         Chat: {
           message: updatedMessage,
           mutation: "UPDATED",
         },
         senderId: sender.id,
         receiverId: updatedMessage.receiverId,
       });
 
       return updatedMessage;
     },
   });

Para a mutação `deleteChat`, adicione:

t.field("deleteChat", {
     type: "Chat",
     args: {
       messageId: intArg({ nullable: false }),
     },
     resolve: async (parent, { messageId }, ctx) => {
       const sender = ctx.user;
 
       if (!sender) {
         throw new Error(
           "You are not logged in. Please login to your account."
         );
       }
 
       const sentMessage = await ctx.prisma.chat.findOne({
         where: {
           id: Number(messageId)
         }
       });
       if (!sentMessage) {
         throw new Error("This message does not exist.");
       }
 
       if (Number(sentMessage.senderId) !== Number(sender.id)) {
         throw new Error("You don't have the permission to delete this message. You can only delete messages created by you.")
       }
 
       const deletedMessage = await ctx.prisma.chat.delete({
         where: {
           id: sentMessage.id
         }
       });
 
       await ctx.pubSub.publish("DELETED", {
         Chat: {
           message: deletedMessage,
           mutation: "DELETED",
         },
         senderId: sender.id,
         receiverId : deletedMessage.receiverId,
       });
 
       return deletedMessage;
     },
   });

Agora, vamos aproveitar a funcionalidade CRUD para o chat e a consulta de chats. No arquivo Query.js, adicione:

t.crud.chat();
    t.crud.chats({
     ordering: true,
     filtering: true,
     pagination: true,
   });

Temos trabalhado nos aspectos de mutação, consulta e assinatura do aplicativo. Criamos permissões de baixo nível e mecanismos de autorização para garantir a segurança de alguns recursos do aplicativo, mas agora é hora de proteger nossa API. Vamos trabalhar nas permissões.

Idealmente, não queremos tornar todos os recursos do aplicativo privados ou inacessíveis a usuários não autenticados. Também não queremos tornar tudo acessível; então, como resolvemos isso?

Por enquanto, tornaremos as consultas à lista de usuários acessíveis a usuários não autenticados, enquanto outros recursos permanecerão protegidos. Também adicionaremos permissões extras para alguns resolvers, a fim de garantir que apenas administradores possam realizar determinadas operações, como a exclusão de um usuário. Vamos começar. Vamos utilizar o Graphql-shield. Aqui está um bom tutorial que aborda os conceitos básicos do GraphQL-shield.

No arquivo permissions/rules.js, adicione:

const { rule } = require("graphql-shield");

const errorMessage = "You are not logged in, please login to your account";
const rules = {
  isAuthenticatedUser: rule({ cache: "contextual" })(
    async (parent, args, ctx) => {
      const loggedInUser = ctx.user;

      if (!loggedInUser) {
        return new Error(errorMessage);
      }
      return true;
    }
  ),
  isAdmin: rule({ cache: "contextual" })(async (parent, args, ctx) => {
    const loggedInUser = ctx.user;

    if (!loggedInUser) {
      return new Error(errorMessage);
    }
    if (!loggedInUser.isAdmin) {
      return new Error(
        "You don't have the right permission to make this request"
      );
    }
    return loggedInUser.isAdmin;
  }),
  isChatOwner: rule({ cache: "strict" })(async (parent, args, ctx) => {
    const loggedInUser = ctx.user;

    if (!loggedInUser) {
      return new Error(errorMessage);
    }

    const chatOwner = await ctx.prisma.chat
      .findOne({
        where: {
          id: Number(args.messageId),
        },
      })
      .sender();

    if (chatOwner.id !== loggedInUser.id) {
      return new Error(
        "You don't have the right permission to make this request. "
      );
    }
    return true;
  }),
};

module.exports = rules;

Em seguida, no arquivo permissions/index.js, aplicamos as regras que definimos a cada um dos nossos resolvedores.

const { shield, or } = require("graphql-shield");
const rules = require("./rules");

const permissions = shield(
  {
    Query: {
      myAccount: rules.isAuthenticatedUser,
      user: or(rules.isAuthenticatedUser, rules.isAdmin),
      chat: or(rules.isAuthenticatedUser, rules.isAdmin),
      chats: or(rules.isAuthenticatedUser, rules.isAdmin),
    },
    Mutation: {
      updateUser: or(rules.isAuthenticatedUser, rules.isAdmin),
      deleteUser: rules.isAdmin,
      createChat: rules.isAuthenticatedUser,
      updateChat: or(rules.isAdmin, rules.isChatOwner),
      deleteChat: or(rules.isAdmin, rules.isChatOwner),
    },
  },
  {
    allowExternalErrors: true,
  }
);

module.exports = permissions;

Por fim, no arquivo de configuração do servidor do script.js, remova o comentário do campo “middleware” para ativar as permissões.

Desenvolvimento Front-end

O repositório do GitHub já vem com os pacotes necessários e a configuração padrão exigida para acompanhar o código. Execute npm i para instalar as dependências necessárias.

Acesse a pasta “pages” e crie os arquivos “login”, “signup.js” e “index.js”.

Agora, vamos trabalhar no login e no cadastro dos usuários. Antes de prosseguirmos, vamos criar um arquivo Layout.js para incluir em nossas páginas funcionalidades reutilizáveis, como o título do site, o favicon etc.

Na pasta “components”, crie um arquivo chamado Layout.js.

import Head from "next/head";
import { Nav, Navbar } from "react-bootstrap";
import Image from "next/image";
import Link from "next/link";
import Router from "next/router";

	export const logout = () => {
    localStorage.removeItem("token");
    localStorage.removeItem("user");
    document.cookie = `token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT`;
    document.cookie = `user=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT`;
    Router.replace("/login");
	  };

const Layout = (props) => {
  const {
    title = "Awesome Web App",
    navHidden = false,
    height = "100vh",
  } = props;
  return (
    <>
      
        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no">
        <meta httpequiv="content-type" content="text/html;charset=UTF-8">
        <meta charset="utf-8">
        <link rel="icon" href="/assets/img/logo.png" type="image/png" sizes="16x16">
        <title>{title}</title>
      

      {!navHidden && (
        <header>
          <navbar bg="dark" expand="lg" classname="mb-40">
            <navbar.brand href="#home">
              <img src="/assets/img/logo.png" width="100" height="100" classname="d-inline-block align-top" alt="">
            </navbar.brand>
            <nav classname="mr-auto flex-row">
              <link href="/">
                <a classname="text-white mr-2 nav-link">Home</a>
              
              <link href="/profile">
                <a classname="text-white mr-2">Profile</a>
              
              <nav.link classname="text-white" onclick="{(e)" ==""> logout()}>
                Logout
              </nav.link>
            </nav>
          </navbar>
        </header>
      )}
      <main style="{{" display:="" "flex",="" justifycontent:="" "center",="" alignitems:="" height,="" position:="" "relative",="" flex:="" 1,="" }}="">
        {props.children}
      </main>
    
  );
};
export default Layout;

No arquivo login.js, adicione:

import { useState } from "react";
import { Form, Row, Col } from "react-bootstrap";
import { useMutation } from "@apollo/client";
import Mutation from "../src/gql/Mutation";
import { setToken } from "../src/utils/tokenUtils";
import { useRouter } from "next/router";
import Link from "next/link";
import Layout from "../src/components/Layout";
import { getToken } from "../src/utils/tokenUtils";

const Login = (props) => {
  const router = useRouter();
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const redirectTo = router.query?.redirectTo ?? "/";
  const [login, { loading, error }] = useMutation(Mutation.login, {
    variables: {
      password,
      username,
    },
    errorPolicy: "all",
    onCompleted({ login }) {
      if (login) {
        setToken(login);
        setUsername("");
        setPassword("");
        router.push(redirectTo);
      }
    },
  });
  const handleSubmit = (event) => {
    event.preventDefault();
    login();
  };
  return (
    <layout navhidden="{true}">
      <row>
        
          <row classname="align-items-center m-h-100">
            
              <div classname="pb-2 text-center">
                <h4 classname=" d-block">Awesome Chat App</h4>
              </div>
              <h5 classname="text-center fw-400 p-b-20">Login</h5>
              <form method="post" onsubmit="{(e)" ==""> handleSubmit(e)}>
                <form.row>
                  <form.group as="{Col}" md="12" controlid="validationCustom01">
                    <form.control required="" size="lg" type="text" value="{username}" placeholder="phone or email" isinvalid="{Boolean(error" &&="" error.message)}="" onchange="{(e)" ==""> setUsername(e.target.value)}
                      disabled={loading}
                    />
                  </form.control></form.group>

                  <form.group as="{Col}" md="12" controlid="validationCustom02">
                    <form.control required="" size="lg" type="password" placeholder="password" value="{password}" isinvalid="{Boolean(error" &&="" error.message)}="" onchange="{(e)" ==""> setPassword(e.target.value)}
                      disabled={loading}
                    />
                    <form.control.feedback type="{"invalid"}">
                      {error && error.message}
                    </form.control.feedback>
                  </form.control></form.group>

                  <button type="submit" classname="col-md-12 mb-3 btn btn-danger" size="lg">
                    {loading ? "Logging you in" : "Login"}
                  </button>
                </form.row>
              </form>
              <row>
                
                  <link href="/signup">
                    <a href="#!" classname="text-underline">
                      Create Account?
                    </a>
                  
                
                
                  <a href="#!" classname=" float-right text-underline">
                    Forgot Password?
                  </a>
                
              </row>
            
          </row>
        
      </row>
    </layout>
  );
};

export async function getServerSideProps(context) {
  const token = getToken(context);

  if (token) {
    return {
      redirect: {
        permanent: false,
        destination: "/",
      },
    };
  }

  return {
    props: {},
  };
}
export default Login;

No método `getServerSideProps`, verificamos se o token existe. Se o token existir, isso significa que o usuário não está conectado, e redirecionamos o usuário para a página inicial. Se o token não existir, seguimos em frente. As importações necessárias, como `setToken`, `Mutation` e `useMutation`, são importadas. Definimos uma interface de usuário básica para o login, com campos para nome de usuário e senha. Esse nome de usuário aceita um e-mail ou número de telefone. Se recebermos uma resposta positiva do servidor, definimos o token e redirecionamos o usuário para a URL necessária definida na redirecTo variável. Caso contrário, se as credenciais de login estiverem incorretas, exibimos esta mensagem ao usuário. Se você não entender como o hook useMutation funciona, leia a documentação do Apollo Client aqui.

No arquivo Signup.js, adicione:

import { useState } from "react";
import { Form, Row, Col } from "react-bootstrap";
import { useMutation } from "@apollo/client";
import Mutation from "../src/gql/Mutation";
import { setToken } from "../src/utils/tokenUtils";
import { useRouter } from "next/router";
import Link from "next/link";
import Layout from "../src/components/Layout";

const SignUp = (props) => {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [phone, setPhone] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");

  const [signup, { loading, error }] = useMutation(Mutation.signup, {
    variables: {
      email,
      phone,
      password,
      name,
    },
    errorPolicy: "all",
    onCompleted({ signup }) {
      if (signup) {
        setToken(signup);
        setEmail("");
        setName("");
        setPhone("");
        setPassword("");
        router.push(`/`);
      }
    },
  });
  const handleSubmit = (event) => {
    event.preventDefault();
    signup();
  };

  return (
    <layout navhidden="{true}">
      <row classname="align-items-center m-h-100">
        
          <div classname="pb-2 text-center">
            <h4 classname=" d-block">Awesome Chat App</h4>
          </div>
          <h5 classname="text-center fw-400 p-b-20">Signup</h5>
          <form method="post" onsubmit="{(e)" ==""> handleSubmit(e)}>
            <form.row>
              <form.group as="{Col}" md="12" controlid="validationCustom01">
                <form.control required="" size="lg" type="text" value="{name}" placeholder="Full Name" onchange="{(e)" ==""> setName(e.target.value)}
                  disabled={loading}
                  isInvalid={Boolean(error && error.message)}
                />
              </form.control></form.group>
              <form.group as="{Col}" md="12" controlid="validationCustom02">
                <form.control required="" size="lg" type="text" value="{phone}" placeholder="Phone" onchange="{(e)" ==""> setPhone(e.target.value)}
                  disabled={loading}
                  isInvalid={Boolean(error && error.message)}
                />
              </form.control></form.group>
              <form.group as="{Col}" md="12" controlid="validationCustom03">
                <form.control size="lg" type="email" value="{email}" placeholder="Email" onchange="{(e)" ==""> setEmail(e.target.value)}
                  disabled={loading}
                  isInvalid={Boolean(error && error.message)}
                />
              </form.control></form.group>

              <form.group as="{Col}" md="12" controlid="validationCustom04">
                <form.control required="" size="lg" type="password" placeholder="Password" value="{password}" onchange="{(e)" ==""> setPassword(e.target.value)}
                  disabled={loading}
                  isInvalid={Boolean(error && error.message)}
                />

                <form.control.feedback type="{"invalid"}">
                  {error && error.message}
                </form.control.feedback>
              </form.control></form.group>

              <button type="submit" classname="col-md-12 mb-3 btn btn-danger" size="lg">
                {loading ? "Signing you up..." : "Signup"}
              </button>
            </form.row>
          </form>
          <row>
            
              <link href="/login">
                <a href="#!" classname="text-underline">
                  Have an account? Sign In.
                </a>
              
            
          </row>
        
      </row>
    </layout>
  );
};
export default SignUp;

Agora, acesse seu navegador e teste o login e o cadastro.

Em seguida, crie os arquivos index.js e chat.js. No arquivo index.js, adicione:

import { useState, useMemo } from "react";
import { Card, Row, Col } from "react-bootstrap";
import Layout from "../src/components/Layout";
import Query from "../src/gql/Query";
import { initializeApollo } from "../src/utils/apolloClient";
import Link from "next/link";
import cookies from "next-cookies";
import { getToken } from "../src/utils/tokenUtils";

const UserPage = (props) => {
  const [users] = useState(props.users);
  const [search, setSearch] = useState("");

  const searchableUsers = users.filter((user) => {
    return (
      user?.name?.toLowerCase().includes(search) ||
      user?.email?.toLowerCase().includes(search) ||
      user?.phone?.toLowerCase().includes(search)
    );
  });

  const memoizedUsers = useMemo(() => {
    return searchableUsers.map(
      (user, i) => {
        return (
          <row classname="border-bottom mb-2" key="{i}">
            
              <p>{user.name}</p>
            
            
              <link href="{`/chat?receiverId=${user.id}`}">
                <a classname="btn btn-link">Start chatting</a>
              
            
          </row>
        );
      },
      [searchableUsers]
    );
  });

  // View Layer
  return (
    <layout title="All Users">
      <card classname="md-width">
        <card.header classname="bg-danger text-white">All Users </card.header>
        <card.body>
          <row classname="mb-3 pb-2 border-dark border-bottom">
            <input type="text" classname="form-control w-100" placeholder="search for a user by name, phone or email address" onchange="{(e)" ==""> setSearch(e.target.value.toLowerCase())}
            />
          </row>
          <div classname="overflow-auto">{memoizedUsers}</div>
        </card.body>
      </card>
      <style jsx="" global="">
        {`
          .md-width {
            width: 100%;
            height: 600px;
            margin-top: -60px;
            overflow: auto;
          }
          .overflow-auto {
            overflow: auto;
            height: 90%;
          }

          @media (min-width: 768px) {
            .md-width {
              max-width: 800px !important;
              margin-top: -190px;
            }
          }
        `}
      </style>
    </layout>
  );
};

export async function getServerSideProps(context) {
  const token = getToken(context);

  if (!token) {
    return {
      redirect: {
        permanent: false,
        destination: "/login?redirectTo=/",
      },
    };
  }
  const apolloClient = initializeApollo();

  const { data, error } = await apolloClient.query({
    query: Query.users,
  });
  const user = cookies(context).user;

  const users =
    data?.users?.filter((val) => Number(val.id) !== Number(user?.id)) ?? [];

  if (!error) {
    return {
      props: {
        users,
      },
    };
  }

  return {
    props: {
      users: [],
      error,
    },
  };
}
export default UserPage;

No método `getServerSideProps`, verificamos se há um cookie de token. Se o token não existir, o usuário é redirecionado para a página de login. Se o token existir, o usuário conectado é filtrado antes de retornarmos os dados como props. O método `filter` da matriz é usado para lidar com a pesquisa no lado do cliente, e o hook `useMemo` do React para memorizar os dados do usuário e evitar uma renderização desnecessária.

Você pode encontrar as consultas GraphQL utilizadas nessas páginas na pasta `gql` do diretório do projeto.

Em seguida, crie um componente ChatBubble na pasta component. Vamos usar o pacote day.js para lidar com os carimbos de data e hora das mensagens. Instale o pacote npm install dayjs e crie um arquivo formatDate na pasta utils. No arquivo formatDate.js, adicione:

import dayjs from "dayjs";
import Calendar from "dayjs/plugin/calendar";
import updateLocale from "dayjs/plugin/updateLocale";

dayjs().format();
dayjs.extend(Calendar);
dayjs.extend(updateLocale);

dayjs().calendar();

export const formateDate = (date = "") => {
  const sameElse = "D/M/YYYY h:mm A";
  const dateStyle = {
    sameElse,
    lastDay: "[Yesterday] h:mm A",
    sameDay: "[Today] h:mm A",
    nextDay: "[Tomorrow] h:mm A",
    lastWeek: sameElse,
    nextWeek: sameElse,
  };

  //   let defaultDate;
  dayjs.updateLocale('en', {
    calendar: dateStyle
  });

  if (!Boolean(date) || date.length === 0) {
    return dayjs().calendar();
  }
  return dayjs(date).calendar();
};

Em seguida, no ChatBubble.js, insira:

import React from "react";
import { formateDate } from "../utils/dateFormatter";

const ChatBubble = React.forwardRef((props, ref) => {
  const { chat = {} } = props;
  const isMyMessage = props.receiverId !== chat?.sender?.id;
  return (
    <section>
      <div style="{{" display:="" "flex",="" flexdirection:="" ismymessage="" ?="" "row-reverse"="" :="" "row",="" }}="" ref="{ref}">
        <div classname="bubble bubble-bottom-left">
          {chat?.message}
          <p classname="{`${" ismymessage="" ?="" "text-muted"="" :="" ""="" }="" text-right="" mt-4="" font-small`}="">
            {formateDate(chat?.createdAt ?? "")}
          </p>
        </div>
      </div>
      <style jsx="">
        {`
          .bubble {
            position: relative;
            font-family: sans-serif;
            font-size: 18px;
            line-height: 24px;
            min-width: 200px;
            max-width: 80%;
            background: ${isMyMessage ? "#000" : "#DC3545"};
            opacity: 0.7;
            color: #fff;
            border-radius: 40px;
            padding: 18px;
            text-align: center;
            margin-bottom: 50px;
            min-height: 20px;
          }

          .bubble-bottom-left:before {
            content: "";
            width: 0px;
            height: 0px;
            position: absolute;
            border-left: 24px solid ${isMyMessage ? "#000" : "#DC3545"};
            border-right: 12px solid transparent;
            border-top: 12px solid ${isMyMessage ? "#000" : "#DC3545"};
            border-bottom: 20px solid transparent;
            ${!isMyMessage ? `left: 32px;` : "right: 32px;"}
            bottom: -24px;
          }

          .font-small {
            font-size: 12px;
          }
        `}
      </style>
    </section>
  );
});
export default ChatBubble;

Dependendo do objeto de bate-papo recebido, aplicamos vários estilos, como a direção do balão de bate-papo, caso o remetente seja o usuário conectado no momento.

No arquivo chat.js, adicione:

import { useState, useEffect, useRef } from "react";
import { Card, Row, Col, Button } from "react-bootstrap";
import Layout from "../src/components/Layout";
import Query from "../src/gql/Query";
import Mutation from "../src/gql/Mutation";
import Subscription from "../src/gql/Subscription";
import ChatBubble from "../src/components/ChatBubble";
import { useRouter } from "next/router";
import { useQuery, useMutation } from "@apollo/client";
import { getToken } from "../src/utils/tokenUtils";
import cookies from "next-cookies";

const ChatPage = (props) => {
  const router = useRouter();
  const myRef = useRef(null);
  const [search, setSearch] = useState("");
  const [message, setMessage] = useState("");
  const receiverId = Number(router.query?.receiverId);

  const { subscribeToMore, data, loading } = useQuery(Query.chats, {
    variables: {
      senderId: Number(props?.user?.id),
      receiverId,
    },
  });

  const [createChat] = useMutation(Mutation.createChat);

  // subscription hook
  useEffect(() => {
    subscribeToMore({
      document: Subscription.chats,
      variables: {
        receiverId: Number(router.query?.receiverId),
      },
      updateQuery: (prev, { subscriptionData }) => {
        if (!subscriptionData.data) {
          return prev;
        }
        const newMessage = subscriptionData.data.Chat.message;
        return {
          chats: [...prev.chats, newMessage],
        };
      },
    });
  }, []);

  // scroll to bottom hook
  useEffect(() => {
    if (myRef) {
      myRef?.current?.scrollIntoView();
    }
  }, [data?.chats, myRef]);

  if (loading) {
    return <div>loading</div>;
  } else {
    const searchableChats =
      data?.chats?.filter((chat) => {
        return chat?.message.includes(search);
      }) ?? [];

    //   profile of the receiver
    const receiverProfile = data?.chats.find(
      (chat) => Number(chat.receiver.id) === receiverId
    );

    // View Layer
    return (
      <Layout title={`Chatting with: ${receiverProfile.receiver.name} `}>
        <Card className="md-width border-bottom-0">
          <Card.Header className="py-4 bg-danger text-white">
            Chatting with: {receiverProfile.receiver.name}
          </Card.Header>
          <Card.Body>
            <Row className="mb-3 pb-2 border-dark border-bottom">
              <input
                type="text"
                className="form-control w-100"
                placeholder="search for chat..."
                onChange={(e) => setSearch(e.target.value.toLowerCase())}
              />
            </Row>
            <Row className="overflow-auto">
              {searchableChats.map((chat, i) => {
                return (
                  <Col xs="12" key={i}>
                    <ChatBubble
                      chat={chat}
                      receiverId={receiverId}
                      ref={myRef}
                    />
                  </Col>
                );
              })}
            </Row>
            <Row className="chat-message clearfix">
              <Col xs="12" md="10">
                <div>
                  <textarea
                    name="message-to-send"
                    value={message}
                    id="message-to-send"
                    placeholder="Type your message"
                    rows="3"
                    onChange={(e) => setMessage(e.target.value)}
                    onKeyPress={(event) => {
                      if (event.key === "Enter") {
                        createChat({
                          variables: {
                            message,
                            receiverId,
                          },
                        });
                        setMessage("");
                      }
                    }}
                  />
                </div>
              </Col>
              <Col md="2" className="align-self-center">
                <Button
                  size="lg"
                  variant="danger"
                  onClick={(e) => {
                    createChat({
                      variables: {
                        message,
                        receiverId,
                      },
                    });
                    setMessage("");
                  }}
                >
                  Send
                </Button>
              </Col>
            </Row>
          </Card.Body>
        </Card>
        <style jsx global>
          {`
            .md-width {
              width: 100%;
              height: 600px;
              margin-top: -60px;
            }
            .overflow-auto {
              overflow: auto;
              height: 90%;
            }

            @media (min-width: 768px) {
              .md-width {
                max-width: 800px !important;
                margin-top: -190px;
              }
            }

            .chat-message textarea {
              width: 100%;
              padding: 10px 20px;
              font: 14px/22px "Lato", Arial, sans-serif;
              margin-bottom: 10px;
              border-radius: 5px;
              resize: none;
              margin-top: 10px;
            }

            .chat-message button:hover {
              color: #75b1e8;
            }
          `}
        </style>
      </Layout>
    );
  }
};

export async function getServerSideProps(context) {
  const token = getToken(context);

  if (!token) {
    return {
      redirect: {
        permanent: false,
        destination: `/login?redirectTo=chat?receiverId=${context.query.receiverId}`,
      },
    };
  }

  const user = cookies(context).user;
  return {
    props: {
      user,
    },
  };
}
export default ChatPage;

Então, vamos entender o que está acontecendo aqui. Primeiro, verificamos se existe um cookie de token. Se ele não existir, redirecionamos o usuário para a página de login e passamos a página de bate-papo como parâmetro da função `redirectTo`. Se houver um token, a carga útil do objeto do usuário é adicionada à propriedade, e os dados do chat são buscados no cliente usando o hook `useQuery`. Isso nos dá acesso a um método chamado subscribeToMore , que chamamos no useEffect hook para lidar com a assinatura de mais mensagens e receber as atualizações instantaneamente. O subscribeToMore método aceita a consulta de assinatura, as variáveis necessárias e um método `updateQuery` que indica como lidar com a nova mensagem recebida. Para mais informações sobre como isso funciona, consulte a documentação tem um guia útil. O hook `useMutation` lida com a criação de uma nova mensagem.

Agora, vamos trabalhar em uma página de perfil onde o usuário possa visualizar e atualizar seu perfil antes de procedermos à implantação na nuvem.

Para começar, crie o arquivo profile.js. Em seguida, adicione o seguinte a ele:

import { useState } from "react";
import { Card, Form, Toast } from "react-bootstrap";
import Layout, { logout } from "../src/components/Layout";
import Mutation from "../src/gql/Mutation";
import cookies from "next-cookies";
import { getToken } from "../src/utils/tokenUtils";
import { useMutation } from "@apollo/client";

const ToastComponent = (props) => {
  return (
    <Toast
      style={{
        minHeight: "100px",
        minWidth: "300px",
        position: "absolute",
        top: 0,
        right: 0,
        zIndex: 1,
        marginRight: 40,
      }}
    >
      <Toast.Header>
        <img
          src="/assets/img/logo.png"
          width="60"
          className="rounded mr-2"
          alt=""
        />
        <strong className="mr-auto">{props.type}</strong>
      </Toast.Header>
      <Toast.Body>{props.message}</Toast.Body>
    </Toast>
  );
};

const ProfilePage = (props) => {
  const [name, setName] = useState(props.user.name);
  const [email, setEmail] = useState(props.user.email);
  const [phone] = useState(props.user.phone);
  const [disabled, setDisabled] = useState(true);
  const [showToast, setShowToast] = useState(false);
  const [updateUser, { error }] = useMutation(Mutation.updateUser, {
    variables: {
      name: {
        set: name,
      },
      phone: {
        set: phone,
      },
      email: {
        set: email,
      },
      id: props.user.id,
    },
    errorPolicy: "all",
    onCompleted({ updateUser }) {
      if (updateUser) {
        setShowToast(!showToast);

        setTimeout(() => {
          logout();
        }, 1300);
      }
    },
  });

  // View Layer
  return (
    <div className="position-relative">
      {showToast && (
        <ToastComponent
          type={error ? "Error" : "Success"}
          message={
            error
              ? error.message
              : "Updated Sucessfully, you would be redirected to the login page"
          }
        />
      )}
      <Layout title={`${props.user.name}'s profle`}>
        <Form
          method="post"
          onSubmit={(e) => {
            e.preventDefault();
            updateUser();
          }}
        >
          <Card className="md-width">
            <Card.Header className="bg-danger text-white">
              <p>
                Once you change your details, you would be redirected to the
                login page to login with your new credentials.
              </p>
            </Card.Header>
            <Card.Body>
              <fieldset disabled={disabled}>
                <Form.Row>
                  <Form.Group className="w-100">
                    <Form.Control
                      defaultValue={name}
                      name="name"
                      onChange={(e) => setName(e.target.value)}
                    />
                  </Form.Group>
                </Form.Row>
                <Form.Row>
                  <Form.Group className="w-100">
                    <Form.Control
                      defaultValue={email}
                      name="email"
                      onChange={(e) => setEmail(e.target.value)}
                      required={Boolean(email)}
                    />
                  </Form.Group>
                </Form.Row>
                <Form.Row>
                  <Form.Group className="w-100">
                    <Form.Control defaultValue={phone} name="phone" readOnly />
                  </Form.Group>
                </Form.Row>
              </fieldset>
              <Form.Group className="text-center">
                <a
                  className="btn btn-link text-danger px-3 mx-3"
                  onClick={(e) => setDisabled(!disabled)}
                >
                  Edit
                </a>
                <button
                  className="btn btn-success  px-3"
                  type="submit"
                  disabled={disabled}
                  onClick={(e) => setDisabled(false)}
                >
                  Save
                </button>
              </Form.Group>
            </Card.Body>
          </Card>
        </Form>

        <style jsx global>
          {`
            .md-width {
              width: 100%;
              min-height: 300px;
              margin-top: -60px;
              overflow: auto;
            }

            @media (min-width: 768px) {
              .md-width {
                max-width: 400px !important;
                margin-top: -190px;
              }
            }
          `}
        </style>
      </Layout>
    </div>
  );
};

export async function getServerSideProps(context) {
  const token = getToken(context);

  if (!token) {
    return {
      redirect: {
        permanent: false,
        destination: "/login?redirectTo=/profile",
      },
    };
  }

  const user = cookies(context).user;

  return {
    props: {
      user,
    },
  };
}
export default ProfilePage;

Tornamos editáveis apenas os campos de nome e e-mail. Se a atualização for bem-sucedida, exibimos uma mensagem de notificação ao usuário e o redirecionamos para a página de login após 1,3 segundos, para que ele faça o login com as novas credenciais.

Implantação

Já criamos o servidor GraphQL e também desenvolvemos um aplicativo com o NextJS para acessar esses endpoints. As APIs do servidor estão protegidas, assim como as páginas. Como temos desenvolvido localmente, agora é hora de disponibilizar o aplicativo para o mundo. Vamos implantar o servidor no Heroku e o aplicativo cliente no Vercel. Vou mostrar a vocês duas maneiras de implantar o aplicativo. Uma usando a interface de linha de comando e a outra usando o painel do Vercel. Crie um Account no Vercel aqui. Crie um Account no Heroku aqui.

Além disso, instale a CLI do Vercel com npm i -g vercel. Instale o CLI do Heroku seguindo as instruções aqui.

Crie dois novos branches. Um chamado production/server e outro chamado production/client. Vamos trabalhar primeiro na implantação do servidor.

Vamos excluir os arquivos que não precisamos manter no ambiente de produção. Na pasta Prisma, exclua o arquivo dev.db e também a pasta migration. Em vez disso, usaremos um banco de dados PostgreSQL para o ambiente de produção. Podemos obter um banco de dados PostgreSQL gratuito no ElephantSql. Crie uma Account e crie um novo banco de dados Postgres. Navegue até o diretório backend, copie a URL do banco de dados e substitua-a no seu arquivo .env. No arquivo prisma/schema.prisma, substitua a configuração da fonte de dados db por:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
// rest of the code

Temos um novo banco de dados; é hora de executar as migrações. Executar

npx prisma migrate save --experimental`** and **`npx prisma migrate up --experimental

No terminal, execute o comando `heroku login` e insira suas credenciais. Em seguida, execute

heroku create < name-of-your-app >. Vamos definir as variáveis de ambiente utilizadas pelo aplicativo. Para definir uma variável de configuração, use o heroku config:set < name >= < value > -a < namme-of-your-app> por exemplo, para minha variável SALTROUND

heroku config:set SALTROUND=10 -a vongage-graphql-api

Como nosso diretório raiz contém tanto o código do cliente quanto o do servidor, e o Heroku não oferece suporte fácil à implantação em subdiretórios, teremos que fazer a implantação usando um buildpack diferente. Para isso, siga as instruções deste artigo aqui. Depois de adicionar as variáveis de configuração necessárias, execute:

heroku buildpacks:clear
heroku buildpacks:set https://github.com/timanovsky/subdir-heroku-buildpack
heroku buildpacks:add heroku/nodejs
heroku config:set PROJECT_PATH=Backend

Em seguida, adicione isto aos seus scripts no arquivo package.json:

"heroku-postbuild": "npm run postinstall"

O script que acabamos de adicionar garante que, após a instalação das dependências do projeto, o Heroku gere a instância necessária do banco de dados Prisma.

Confirme se o Heroku faz parte do repositório remoto executando git remote -v. Se você não encontrar um remoto chamado Heroku, adicione-o executando heroku git:remote -a <app-name>.

Por fim, execute o git git push heroku master. Isso enviará seu código e o compilará. Se você encontrar algum problema, certifique-se de ter definido todas as variáveis de ambiente que seu aplicativo usa e de seguir as instruções cuidadosamente. Em seguida, execute heroku apps:open /playground.

Front-end

Certifique-se de ter enviado todas as suas alterações para o GitHub no branch production/server. Crie um novo branch a partir dele, chamado production/client, e substitua os respectivos endpoints do GraphQL para as consultas e mutações pela sua URL de produção. Para assinaturas, substitua o protocolo HTTPS por WSS. Envie as alterações para o GitHub e acesse sua conta no Vercel.

No seu painel,

  • Clique em “Importar projeto”, copie o link do seu repositório do GitHub e cole-o

  • Escolha o diretório frontend como diretório raiz do seu projeto e um nome

  • Ele deve detectar automaticamente o NextJS como a estrutura de escolha. Não estamos usando nenhuma variável de ambiente, então deixe essa parte em branco. Em seguida, clique em “Implantar”.

  • Assim que seu projeto estiver totalmente implantado, acesse a aba “Configurações”. No Git, altere o branch de implantação de “main” para “production/client”.

  • Acesse seu código, faça uma alteração nos arquivos, faça o commit e envie para o branch production/client. O projeto será compilado automaticamente e iniciado.

  • Assim que a implantação estiver concluída, acesse o painel do Heroku. No seu aplicativo de servidor, acesse a aba “Configurações” e altere a URL FRONTEND_ORIGIN para a URL do cliente de produção que acabamos de implantar no Vercel. Certifique-se de não adicionar uma barra no final da URL. Por exemplo, deve ser www.example.com, e não www.example.com/

  • Pronto, já terminamos.

Conclusão

É um artigo longo, mas tenho certeza de que valerá a pena conferir. Resumindo, abordamos como criar um servidor GraphQL que também funcione com assinaturas, utilizando a abordagem “code-first”. Aprofundamos o conhecimento sobre a SMS API da Vonage e a utilizamos para enviar notificações por SMS aos usuários. Usamos o fantástico ORM de banco de dados Prisma 2 para lidar com as consultas ao banco de dados. E não podemos esquecer que acessamos os endpoints usando o Apollo e o NextJS. Por fim, fizemos a implantação no serviço de hospedagem Vercel e no Heroku usando a CLI e a GUI. Acredito firmemente que já lhe forneci tudo o que você precisa para transformar sua próxima grande ideia em realidade usando essas ferramentas. Meu desafio para vocês é levar essa aplicação a um novo patamar, adicionando mais funcionalidades, como redefinição de senha, adição de amigos, postagens etc.

Obrigado por dedicar seu tempo para experimentar este tutorial. Se tiver alguma dúvida, não hesite em entrar em contato comigo no Twitter em @codekagei ou deixar um comentário. Boa programação!

Consulte o código:

Compartilhar:

https://a.storyblok.com/f/270183/400x509/d3a155b385/temiloluwa-ojo.png
Temiloluwa OjoAutor convidado

Temiloluwa é um aprendiz apaixonado, focado em criar soluções acessíveis e centradas no usuário que resolvam problemas globais. Ele trabalha com React, Node.js e GraphQL e possui experiência básica em Java. Ele adora explorar novas tecnologias e linguagens de programação e tem um talento especial para dividir temas complexos em pequenas partes fáceis de entender. Atualmente, está aberto a oportunidades que ofereçam a combinação certa de desafio e crescimento.