https://d226lax1qjow5r.cloudfront.net/blog/blogposts/building-a-realtime-graphql-chat-application-with-sms-notifications/nextjs_prisma-2_graphql_sms-1200x600.png

Aufbau einer Echtzeit-GraphQL-Chat-Anwendung mit SMS-Benachrichtigungen

Zuletzt aktualisiert am February 25, 2021

Lesedauer: 15 Minuten

Mit dem Aufkommen von GraphQL entstand eine neue Möglichkeit für Entwickler, Client/Server-Anwendungen zu entwickeln. Die Vorteile der Entwicklung von GraphQL-Anwendungen sind zahlreich, von der expliziten Anforderung der benötigten Daten vom Server bis hin zur ereignisgesteuerten Kommunikation in Echtzeit durch Abonnements. In diesem Artikel werden das Code-first-GraphQL und seine Superkräfte vorgestellt. Der Artikel beschreibt auch, wie man eine Chat-Anwendung entwickelt, die von Next.js und Apollo auf dem Frontend, und Prisma 2, Graphql-yoga und SMS-Benachrichtigung über die hervorragende Vonage SMS API im Backend.

Code-First GraphQL

Code-first GraphQL ist ein Ansatz zur Entwicklung von GraphQL-Servern, bei dem Sie Ihre Resolver schreiben und die Schemadefinition auslagern, um sie programmatisch zu generieren. Dieser Ansatz wird oft auch als "Resolver First"-Ansatz bezeichnet. Die Generierung des Schemas wird von einem Tool übernommen, das Ihre Resolver durchläuft und das Schema generiert. Schema first ist das Gegenteil des Code-first-Ansatzes und beinhaltet die Definition der Typen, Antworten usw. Ihres Servers.

Backend-Entwicklung

Wir werden eine Echtzeit-Chat-Anwendung mit SMS-Benachrichtigungen entwickeln.

Um zu beginnen, klonen Sie dieses Repository. Es enthält die grundlegenden Einstellungen, die Sie in diesem Artikel vornehmen müssen.

Voraussetzungen

  • Node.js >=10.0.0

  • Vorkenntnisse über Prisma

  • Verständnis von GraphQL

  • Eine Datenbank, z.B. MySQL

  • Prisma CLI

  • Ein Vonage-Konto

Vonage API-Konto

Um dieses Tutorial durchzuführen, benötigen Sie ein Vonage API-Konto. Wenn Sie noch keines haben, können Sie sich noch heute anmelden und mit einem kostenlosen Guthaben beginnen. Sobald Sie ein Konto haben, finden Sie Ihren API-Schlüssel und Ihr API-Geheimnis oben auf dem Vonage-API-Dashboard.

Jetzt wollen wir das Projektverzeichnis verstehen.

Im Stammverzeichnis befinden sich zwei Ordner. Das Backend-Verzeichnis enthält einen Prisma-Ordner, der die Prisma-Konfiguration enthält. Im Prisma-Ordner befindet sich eine Datei schema.prisma, die die Konfiguration des Datenbank-Setups und eine SQLite-DB namens dev.db enthält. Navigieren Sie zum Backend-Verzeichnis und führen Sie npm install aus, um alle erforderlichen Abhängigkeiten zu installieren.

Erstellen Sie außerdem eine .env-Datei im Backend-Verzeichnis; diese enthält die notwendigen Umgebungsvariablen wie Datenbank-URL und Variablen und all das. Für die Datenbank-URL fügen Sie diese in die env-Datei ein:

DATABASE_URL="file:./dev.db"

Das Verzeichnis pages im Frontend-Ordner ist der Ort, an dem Next.js die Seiten der Anwendung bereitstellt. Das pages-Verzeichnis enthält eine _app.js, die für die Arbeit mit Bootstrap eingerichtet wurde. Navigieren Sie zum Frontend-Verzeichnis und führen Sie npm install. Dieser Ordner enthält auch ein Verzeichnis src mit den Unterverzeichnissen assets, components und utils.

Navigieren Sie dann zur Datei prisma/schema.prisma. Wir benötigen zwei Modelle, eines für User und eines für Chat. Nachfolgend sehen Sie die Konfiguration des Generator-Clients:

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())
}

Das Modell steht für den Tabellennamen, der in der Datenbank erstellt wird, während die Felder für die Spaltennamen und die Datentypen stehen, die dort gespeichert werden sollen. Zusätzlich zu den in Prisma verfügbaren Datentypen kann ein Modell auch ein Datentyp sein. Dieser definiert die Beziehung zwischen zwei oder mehreren Modellen oder eine Selbstbeziehung für ein Modell. Jedes Modell wird mit Prisma-Schlüsselwörtern kommentiert. Wenn Sie die verwendeten Schlüsselwörter nicht verstehen, sehen Sie bitte in der Prisma Dokumentation.

ausführen. prisma migrate save --experimental. Benennen Sie Ihre Migration und führen Sie prisma migrate up --experimental. Der Befehl erstellt die Tabellen auf der Grundlage der Modelldefinitionen im Schema. Führen Sie abschließend prisma generate um das Datenbankschema, das den Prisma-Methoden und -Funktionen zugeordnet ist, die CRUD-Funktionen ermöglichen, freizulegen.

Navigieren Sie zum Verzeichnis src/types und erstellen Sie eine User- und Chat-Datei. Vier Dateien sind bereits vorhanden: Mutation.js, Query.js, Subscription.js und eine index.js-Datei, die alle Resolver in einer Datei vereint.

In der Datei User.js hinzufügen:

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,
};

Fügen Sie in der Datei Chat.js hinzu:

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,
};

Wir importieren einen objectType von nexus, da das Benutzer- und das Chatmodell vom Typ Objekt sind. Wir greifen auf die Felder zu, die wir in unserem Schema definiert haben, indem wir die Modellmethode und den Namen des Feldes verwenden. Ermöglicht wird dies durch das nexus-plugin-prisma das wir installiert haben. So müssen wir nicht mehr jedes Feld einzeln definieren und konfigurieren. Der folgende Code ist ein Beispiel für die manuelle Konfiguration:

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

In der Mutationsdatei müssen wir die Anmeldung und die Registrierung behandeln. Erstellen Sie eine neue Datei im Verzeichnis src/types mit dem Namen AuthPayload.js. Dies ist ein Objekttyp, der darstellt, welcher Authentifizierungs-Payload-Typ an den Client zurückgegeben würde.

In AuthPayload.js, hinzufügen:

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

Erstellen Sie im Verzeichnis src/utils eine Datei helper.js und machen Sie diese Methoden.

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,
};

ANMERKUNG: Inzwischen sollten Sie in Ihrer .env-Datei Umgebungsvariablen für diese Methoden festgelegt haben.

Jetzt haben wir eine Möglichkeit, die ID des Benutzers mit einer getUser-Methode zu ermitteln. Ändern wir die Datei script.js. Importieren Sie die getUser-Methode aus src/utils/helpers und entfernen Sie das Kommentarzeichen in diesem Teil der Kontextmethode in der Serverkonfigurationsdatei.

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

Fügen Sie in der Datei mutation.js den Code für die Anmeldung hinzu:

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,
       };
     },
   });  
},
});

Zunächst wird geprüft, ob die Datenbank einen Benutzer mit der E-Mail-Adresse oder der Telefonnummer enthält. Wenn der Benutzer existiert, wird ein Fehler ausgegeben, andernfalls wird der Benutzer erstellt, ein Token mit der Benutzer-ID erzeugt und das Token als Cookie gesetzt. Wir geben auch die Nutzdaten für die Anmeldung zurück.

Fügen Sie Folgendes zu login.js hinzu, direkt unter dem Anmeldungsauflöser:

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,
     };
   },
 });

Wir überprüfen, ob der Benutzer existiert, validieren seine Anmeldedaten, setzen das Token als Cookie und geben den Mutationstyp der Auth-Payload zurück. Außerdem senden wir ihnen eine SMS-Benachrichtigung über die Vonage-SMS-API-Instanz, die wir in der Serverkonfiguration erstellt haben.

Um die SMS-Benachrichtigungen an die Benutzer der Anwendung zu verwalten, habe ich eine virtuelle Nummer von Vonage gekauft. Sie sollten diesen Artikel folgen, um mit der Erstellung einer Vonage-SMS-Anwendung zu beginnen. Sobald Sie eine Anwendung erstellt haben, wird eine private Schlüsseldatei automatisch auf Ihren Computer heruntergeladen. Verschieben Sie diese Datei in das Backend-Verzeichnis. Sie sollten auch eine Umgebungsvariable ADMIN_PHONE in Ihrer .env-Datei setzen, die virtuelle Nummer, die ich von meinem Vonage-Konto gekauft habe.

Fahren Sie fort mit types/index.js-Datei und kommentieren Sie die Importe Query und Subscription aus, da wir dort nichts haben. Importieren Sie jetzt Ihre Dateien User.js und Chat.js.

Fügen wir einen Query Resolver hinzu, damit ein Benutzer seine Kontodaten abfragen kann. Fügen Sie in Query.js hinzu:

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;
      },
    });
   }
})

Da wir die Abfrage des Benutzers bei jeder Anfrage, die wir im Kontextfeld der Serverkonfiguration erhalten, behandelt haben, können wir auf den Benutzer zugreifen, wenn er im ctx-Objekt existiert. Wenn der Benutzer nicht im ctx-Objekt vorhanden ist, muss er sich anmelden. Dekommentieren Sie in der Datei types/index.js die importierte Datei Query.js.

Lassen Sie uns zwei weitere Abfrageauflöser erstellen; einen für die Abfrage eines Benutzers und einen für die Abfrage mehrerer Benutzer. Wir würden die nexus-plugin-prisma crud Funktionalitäten verwenden; dies ist eine experimentelle Funktion, also müssen wir sie einschalten. In der Datei script.js fügen Sie im Feld plugins {experimentalCRUD: true} zur Funktion nexusPrisma hinzu, falls sie noch nicht vorhanden ist.

In Query.js, hinzufügen:

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

Um diese Funktionalität zu nutzen, stellen Sie sicher, dass Sie Ihre Schemamodelle im Singular benennen wie Benutzer und nicht Benutzer. Verwenden wir diese Funktion auch, um die Auflösungen "Einen Benutzer aktualisieren" und "Einen Benutzer löschen" in der Mutationsdatei zu behandeln.

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

Kümmern wir uns jetzt um die Chat-Auflöser. Außerdem fügen wir zwei weitere Dateien hinzu, eine mit dem Namen Subscription.js und die andere SubscriptionPayload. Fügen Sie beide Dateien zu Ihrer Liste der Typen (Resolver) in der Datei index.js hinzu.

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 };

Um Abonnements zu verwalten, verwenden wir die PubSub Methode, die mit dem Graphql-yoga-Paket geliefert wird.

Zunächst erstellen wir CRUD-Funktionen für einen Chat, auf den der Abonnementauflöser auf diese CRUD-Ereignisse warten würde. Außerdem erstellen wir eine Funktion sendNewMessageNotification, die Benachrichtigungen an Chat-Empfänger sendet, wenn die letzten Unterhaltungen zwischen ihnen weniger als eine Stunde zurückliegen.

In der Datei helper.js hinzufügen:

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"]}`
            );
          }
        }
      }
    );
  }
};

Diese Methode prüft, ob die letzte gesendete Nachricht weniger als 1 Stunde zurückliegt. Wenn ja, senden wir dem Empfänger der Nachricht eine SMS-Benachrichtigung, und wenn nicht, unternehmen wir nichts. Exportieren Sie die Methode sendNewMessageNotification und importieren Sie sie in die Datei Mutation.js. Kümmern wir uns nun um den createChat-Auflöser.

In Mutation.js, hinzufügen:

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;
      },
    });

Wir überprüfen, ob der Absender der Nachricht angemeldet ist, indem wir prüfen, ob das Benutzerobjekt auf dem Server existiert, und wir überprüfen auch, ob die Nachricht nicht leer ist. Zunächst wird die letzte vom Benutzer gesendete oder empfangene Nachricht abgefragt, dann wird die neue Nachricht erstellt. Wenn die letzte Nachricht zwischen den beiden weniger als eine Stunde zurückliegt, wird die Methode sendNewMessageNotification ausgelöst. Abschließend behandeln wir den Aspekt des Abonnements.

In Subscription.js hinzufügen:

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,
};

Wir importieren intArg- und subScriptionFIeld-Objekte aus nexus/schema, und wir importieren auch die withFIlter-Methode aus dem graphql-yoga-Paket, die uns dabei hilft, sicherzustellen, dass nur die richtigen Benutzer Nutzdaten oder Ereignisse erhalten. Das erste Argument ist der subscribe resolver, der den asyncIterator zurückgibt, den wir filtern wollen, und dem wir die Ereignisse übergeben, auf die wir hören wollen, d.h. ERSTELLT, AKTUALISIERT, GELÖSCHT. Das zweite Argument ist die Bedingung, die erfüllt sein muss, damit das Ereignis durchgelassen wird. Für unseren Anwendungsfall sollte dieses Ereignis nur an den Sender und den Empfänger der Nachrichtendaten weitergegeben werden. Aus Neugierde kommentieren Sie dieses Feld aus und testen Sie es. Sie sollten feststellen, dass das Senden einer Nachricht alle Benutzer in der Anwendung benachrichtigt, die auf den createChat-Auflöser warten.

Fügen wir nun die Mutationen updateChat und deleteChat hinzu, was sich etwas von der Erstellung eines Chats unterscheidet. Zunächst müssen wir prüfen, ob der Benutzer authentifiziert ist. Zweitens müssen wir prüfen, ob die Nachricht existiert, und schließlich müssen wir prüfen, ob der Absender der Nachricht sie aktualisieren oder löschen kann. Ein Benutzer, der die Nachricht nicht abgeschickt hat, sollte keinen Zugriff auf die Löschung der Nachricht haben. Wenn diese Bedingungen erfüllt sind, aktualisieren oder löschen wir den Chat und benachrichtigen unsere Abonnenten.

Für die updateChat-Mutation, hinzufügen:

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;
     },
   });

Für die deleteChat-Mutation, hinzufügen:

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;
     },
   });

Lassen Sie uns nun die CRUD-Funktionalität für die Chat- und Chats-Abfrage nutzen. Fügen Sie in der Datei Query.js Folgendes hinzu:

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

Wir haben an den Aspekten Mutation, Abfrage und Abonnement der Anwendung gearbeitet. Wir haben Low-Level-Berechtigungen und Autorisierungsmechanismen erstellt, um sicherzustellen, dass einige Funktionen der Anwendung sicher sind, aber jetzt ist es an der Zeit, unsere API zu schützen. Lassen Sie uns an den Berechtigungen arbeiten.

Idealerweise möchten wir nicht alle Funktionen der Anwendung privat oder für nicht authentifizierte Benutzer unzugänglich machen. Wir wollen auch nicht alles zugänglich machen. Wie also lösen wir dieses Problem?

Vorerst werden wir die Abfragen der Benutzerliste für nicht authentifizierte Benutzer zugänglich machen, während andere Funktionen geschützt werden. Wir werden auch zusätzliche Berechtigungen für einige Resolver hinzufügen, um sicherzustellen, dass nur Administratoren bestimmte Operationen wie das Löschen eines Benutzers durchführen können. Fangen wir an. Wir werden Graphql-shield verwenden. Hier ist ein gutes Lehrgang das die Grundlagen von Graphql-shield behandelt.

In permissions/rules.js, hinzufügen:

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;

Dann wenden wir in der Datei permissions/index.js unsere definierten Regeln auf jeden unserer Resolver an.

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;

Schließlich müssen Sie in der Serverkonfigurationsdatei von script.js das Feld middleware auskommentieren, um die Berechtigungen zu aktivieren.

Frontend-Entwicklung

Das GitHub-Repository enthält bereits die benötigten Pakete und Standardeinstellungen, die für die Programmierung erforderlich sind. Führen Sie npm i um die erforderlichen Abhängigkeiten zu installieren.

Navigieren Sie zum Ordner pages und erstellen Sie die Dateien login, signup.js und index.js.

Nun wollen wir uns mit der Anmeldung und Registrierung der Benutzer befassen. Bevor wir fortfahren, erstellen wir eine Layout.js-Datei, um unsere Seiten mit wiederverwendbaren Funktionen wie Website-Titel, Favicon usw. zu umhüllen.

Erstellen Sie im Ordner "components" eine Datei "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;

In login.js, hinzufügen:

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;

In getServerSideProps prüfen wir, ob das Token existiert. Wenn das Token existiert, bedeutet dies, dass der Benutzer nicht angemeldet ist, und wir leiten den Benutzer zur Startseite um. Wenn das Token nicht existiert, fahren wir fort. Erforderliche Importe wie setToken, Mutation und useMutation werden importiert. Wir definieren eine grundlegende Benutzeroberfläche für die Anmeldung und stellen ein Feld für den Benutzernamen und das Passwort bereit. Dieser Benutzername akzeptiert eine E-Mail oder eine Telefonnummer. Wenn wir eine erfolgreiche Antwort vom Server erhalten, setzen wir das Token und leiten den Benutzer zu der erforderlichen URL weiter, die in der redirecTo Variable definiert ist. Andernfalls, wenn die Anmeldedaten nicht korrekt sind, zeigen wir dem Benutzer diese Meldung an. Wenn Sie nicht verstehen, wie der useMutation-Hook funktioniert, lesen Sie bitte die Dokumentation des Apollo-Clients hier.

In Signup.js, hinzufügen:

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;

Navigieren Sie nun zu Ihrem Browser und testen Sie das Einloggen und die Anmeldung.

Als Nächstes erstellen Sie die Dateien index.js und chat.js. Fügen Sie in index.js hinzu:

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;

In getServerSideProps wird nach dem Token-Cookie gesucht. Wenn das Token nicht existiert, wird der Benutzer auf die Anmeldeseite umgeleitet. Ist das Token vorhanden, wird der angemeldete Benutzer herausgefiltert, bevor die Daten als Props zurückgegeben werden. Die Array-Filter-Methode wird verwendet, um die clientseitige Suche zu handhaben, und der useMemo React-Hook, um die Benutzerdaten zu memoisieren, damit sie nicht unnötig neu gerendert werden.

Die GraphQL-Abfragen, die für diese Seiten verwendet werden, finden Sie im Ordner gql des Projektverzeichnisses.

Als nächstes erstellen Sie eine ChatBubble-Komponente im Komponentenordner. Wir werden ein day.js-Paket verwenden, um Zeitstempel für Nachrichten zu verarbeiten. Installieren Sie das Paket npm install dayjs und erstellen Sie eine formatDate-Datei in utils. Fügen Sie in formatDate.js hinzu:

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();
};

Fügen Sie dann in ChatBubble.js ein:

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;

Je nach empfangenem Chat-Objekt wenden wir verschiedene Stile an, z. B. die Richtung der Chat-Blase, wenn der Absender der aktuell eingeloggte Benutzer ist.

In chat.js, hinzufügen:

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;

Lassen Sie uns also verstehen, was hier vor sich geht. Zunächst prüfen wir, ob ein Token-Cookie existiert. Ist dies nicht der Fall, leiten wir den Benutzer auf die Anmeldeseite um und übergeben die Chatseite als redirectTo-Funktion. Wenn ein Token vorhanden ist, wird die Nutzlast des Benutzerobjekts zum prop hinzugefügt, und die Chatdaten werden auf dem Client mit dem useQuery-Hook abgerufen. Dadurch erhalten wir Zugang zu einer Methode namens subscribeToMore die wir in useEffect Hook aufgerufen wird, um weitere Nachrichten zu abonnieren und die Aktualisierungen sofort zu erhalten. Die Methode subscribeToMore Methode akzeptiert die Abonnementabfrage, die benötigten Variablen und eine updateQuery-Methode, die ihr mitteilt, wie sie die neu eingegangene Nachricht behandeln soll. Weitere Informationen darüber, wie dies funktioniert, finden Sie in der Dokumentation eine nützliche Anleitung. Der useMutation-Hook behandelt die Erstellung einer neuen Nachricht.

Lassen Sie uns nun an einer Profilseite arbeiten, auf der der Benutzer sein Profil einsehen und aktualisieren kann, bevor wir die Bereitstellung in der Cloud vornehmen.

Erstellen Sie zunächst die Datei profile.js. Fügen Sie dann Folgendes hinzu:

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;

Wir machen nur die Felder Name und E-Mail editierbar. Wenn die Aktualisierung erfolgreich ist, zeigen wir dem Benutzer eine Toast-Meldung und leiten ihn nach 1,3 Sekunden zur Anmeldeseite weiter, wo er sich mit den neuen Anmeldedaten anmelden kann.

Einsatz

Wir haben uns um die Erstellung des GraphQL-Servers gekümmert und eine mit NextJS erstellte Anwendung entwickelt, die diese Endpunkte nutzt. Die Server-APIs sind geschützt, ebenso wie die Seiten. Da wir lokal entwickelt haben, ist es nun an der Zeit, die Anwendung der Welt zugänglich zu machen. Wir werden den Server auf Heroku und die Client-Anwendung auf Vercel bereitstellen. Ich werde Ihnen zwei Möglichkeiten zeigen, wie Sie die Anwendung bereitstellen können. Zum einen über die Befehlszeilenschnittstelle und zum anderen über das Vercel-Dashboard. Erstellen Sie ein Vercel-Konto hier. Erstellen Sie ein Heroku-Konto hier.

Installieren Sie auch die Vercel CLI mit npm i -g vercel. Installieren Sie die Heroku CLI, indem Sie den Anweisungen hier.

Erstellen Sie zwei neue Zweige. Einen mit dem Namen production/server und einen anderen mit dem Namen production/client. Lassen Sie uns zuerst den Server einrichten.

Löschen wir die Dateien, die wir in der Produktion nicht benötigen. Löschen Sie im Prisma-Ordner die Datei dev.db und auch den Migrationsordner. Wir werden stattdessen eine Postgres-Datenbank für die Produktion verwenden. Wir können eine kostenlose PostgreSQL-Datenbank von ElephantSql erhalten. Erstellen Sie ein Konto und erstellen Sie eine neue Postgres-Datenbank. Wechseln Sie in das Backend-Verzeichnis, kopieren Sie die Datenbank-URL und ersetzen Sie sie in Ihrer .env-Datei. Ersetzen Sie in der Datei prisma/schema.prisma die Datenquelle db setup durch:

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

Wir haben eine neue Datenbank, es ist Zeit, Migrationen durchzuführen. ausführen

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

Führen Sie im Terminal heroku login aus und geben Sie Ihre Anmeldedaten ein. Führen Sie dann

heroku create < name-of-your-app >. Setzen wir nun die von der Anwendung verwendeten Umgebungsvariablen. Um eine Konfigurationsvariable zu setzen, verwenden Sie die heroku config:set < name >= < value > -a < namme-of-your-app> z.B. für meine SALTROUND-Variable

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

Da unser Root-Verzeichnis sowohl den Client- als auch den Server-Code enthält und Heroku das Deployment von Unterverzeichnissen nicht ohne weiteres unterstützt, müssen wir ein anderes Buildpack verwenden. Befolgen Sie dazu diesen Artikel hier. Nachdem Sie Ihre benötigten Konfigurationsvariablen hinzugefügt haben, ausführen:

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

Fügen Sie dies dann zu Ihren Skripten in der Datei package.json hinzu:

"heroku-postbuild": "npm run postinstall"

Das soeben hinzugefügte Skript sorgt dafür, dass Heroku nach der Installation der Abhängigkeiten für das Projekt die erforderliche Prisma-Datenbankinstanz erzeugt.

Bestätigen Sie, dass Heroku Teil des entfernten Repositorys ist, indem Sie git remote -v. Wenn Sie kein entferntes Repository namens Heroku sehen, fügen Sie es hinzu, indem Sie heroku git:remote -a <app-name>.

Zum Schluss führen Sie git git push heroku master. Dadurch wird Ihr Code gepusht und erstellt. Wenn Sie auf Probleme stoßen, stellen Sie sicher, dass Sie alle Umgebungsvariablen gesetzt haben, die Ihre Anwendung verwendet, und dass Sie die Anweisungen sorgfältig befolgt haben. Als nächstes führen Sie heroku apps:open /playground.

Frontend

Vergewissern Sie sich, dass Sie alle Ihre Änderungen auf GitHub in den Zweig production/server gepusht haben. Erstellen Sie von dort aus einen neuen Zweig namens production/client und ersetzen Sie die entsprechenden Graphql-Endpunkte für die Abfragen und die Mutation durch Ihre Produktions-URL. Ersetzen Sie bei Abonnements das HTTPS-Protokoll durch WSS. Übertragen Sie die Änderungen auf GitHub und navigieren Sie zu Ihrem Vercel-Konto.

In Ihrem Dashboard,

  • Klicken Sie auf Projekt importieren, kopieren Sie den Link zu Ihrem GitHub-Repository und fügen Sie

  • Wählen Sie das Frontend-Verzeichnis als Stammverzeichnis für Ihr Projekt und einen Namen

  • Es sollte automatisch NextJS als das Framework der Wahl erkennen. Da wir keine Umgebungsvariable verwenden, lassen Sie diesen Teil leer. Klicken Sie dann auf deploy.

  • Sobald Ihr Projekt vollständig bereitgestellt wurde, wechseln Sie zur Registerkarte "Einstellungen". Ändern Sie in git den Bereitstellungszweig von main zu production/client.

  • Navigieren Sie zu Ihrem Code und nehmen Sie eine Änderung an Ihren Dateien vor, übertragen Sie sie und schieben Sie sie in den Produktions-/Client-Zweig. Dadurch wird Ihr Projekt automatisch erstellt und gestartet.

  • Sobald die Bereitstellung abgeschlossen ist, navigieren Sie zu Ihrem Heroku-Dashboard. Navigieren Sie in Ihrer Serveranwendung zur Registerkarte "Einstellungen" und ändern Sie die URL "FRONTEND_ORIGIN" in die URL des Produktionsclients, den wir gerade auf Vercel bereitgestellt haben. Stellen Sie sicher, dass Sie keinen Schrägstrich am Ende der URL hinzufügen. Sie sollte zum Beispiel www.example.com und nicht www.example.com/ lauten.

  • Voila, wir sind fertig.

Schlussfolgerung

Ein langer Artikel, aber ich bin zuversichtlich, dass es sich lohnt, ihn zu lesen. Zusammenfassend lässt sich sagen, dass wir uns mit der Erstellung eines GraphQL-Servers beschäftigt haben, der auch mit Abonnements nach dem Code-First-Ansatz funktioniert. Wir haben uns in die Vonage SMS API vertieft und sie genutzt, um SMS-Benachrichtigungen an Benutzer zu senden. Wir haben das fantastische Prisma 2 Datenbank ORM verwendet, um die Datenbankabfragen zu handhaben. Nicht zu vergessen, dass wir die Endpunkte mit Apollo und NextJS genutzt haben. Schließlich haben wir das Projekt auf dem Vercel-Hosting-Service und Heroku mit Hilfe der CLI und der GUI bereitgestellt. Ich bin fest davon überzeugt, dass ich Sie mit allem ausgestattet habe, was Sie brauchen, um mit diesen Tools die nächste große Idee zu entwickeln. Meine Herausforderung für Sie ist es, diese Anwendung auf die nächste Stufe zu heben, indem Sie weitere Funktionen wie das Zurücksetzen von Passwörtern, das Hinzufügen von Freunden, Beiträgen usw. hinzufügen.

Vielen Dank, dass Sie sich die Zeit genommen haben, dieses Tutorial auszuprobieren. Wenn Sie nicht weiterkommen, zögern Sie nicht, mich auf Twitter zu erreichen unter @codekagei oder hinterlasse einen Kommentar. Viel Spaß beim Hacken!

Verweisen Sie auf den Code:

Teilen Sie:

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

Temiloluwa is a passionate learner focused on building accessible and user-centred solutions that solve global problems. He works with React, Nodejs and Graphql and has basic experience working with Java. He loves exploring new technologies and languages and has a penchant for breaking complex topics into small chunks of easy to understand bits. He is currently open to opportunities that provide the right mix of challenge and growth.