• Importante: Leitura das Regras

    Você deve ler as Regras do Fórum AxTudo antes de fazer sua primeira postagem. O não cumprimento pode resultar em pontos de advertência permanentes ou em um banimento definitivo.

    Os recursos disponíveis no Fórum AxTudo são LIMPOS e SEGUROS, prontos para serem utilizados em seus projetos de desenvolvimento e testes. Para garantir que você tenha acesso total a todos os conteúdos, recomendamos que desative qualquer bloqueador de anúncios (AdBlock) enquanto navega pelo fórum.

    Aproveite sua experiência no Fórum AxTudo!

Baixe Grátis o Whaticket 5.2.6 + Tutorial Completo de Instalação no aaPanel

Baixe Grátis o Whaticket 5.2.6 + Tutorial Completo de Instalação no aaPanel v5.2.6

Sem permissão para baixar. Registre-se é Grátis

Freitas

Membro conhecido
Membro da equipe
Administrador
Entrou
26 Abril 2021
Mensagens
137
Pontos de reação
108
Pontos
565
Localização
Brasil
Freitas enviou um novo recurso:

Tutorial Completo: Instalação do Whaticket v5.0 no aaPanel com Node.js 16 e 18 - Este tutorial orienta você na instalação do Whaticket v5 no aaPanel, utilizando o Node.js nas...

Este tutorial orienta você na instalação do Waticket no aaPanel, utilizando o Node.js nas versões 16 para o backend e 18 para o frontend. Siga cada passo cuidadosamente para garantir uma instalação bem-sucedida.

whaticket-cover2.png


1. Pré-requisitos: Verifique e Atualize o Sistema

Antes de começar, é essencial garantir que o sistema e todas as dependências estejam atualizadas. Execute os...

Leia mais sobre este recurso ...
 

Freitas

Membro conhecido
Membro da equipe
Administrador
Entrou
26 Abril 2021
Mensagens
137
Pontos de reação
108
Pontos
565
Localização
Brasil
Freitas atualizado Tutorial Completo: Instalação do Whaticket v5.0 no aaPanel com Node.js 16 e 18 com uma nova entrada de atualização:

Tutorial Completo: Instalação do Whaticket v5.2.6 no aaPanel com Node.js 16 e 18

Não é deste autor, porque este é de marca branca, mas é o mesmo Whaticket v5.2.6


Análise do arquivo: Clique aqui Verificado 0/65

Senha:
OU​
Senha...

Leia o resto desta entrada de atualização...
 

Freitas

Membro conhecido
Membro da equipe
Administrador
Entrou
26 Abril 2021
Mensagens
137
Pontos de reação
108
Pontos
565
Localização
Brasil
Quando você encontrar a mensagem "Necessário pontos" em relação a recursos ou troféus, significa que você precisa acumular uma quantidade de pontos igual ou maior para poder baixar o conteúdo. Esses pontos podem ser conquistados ao indicar o fórum usando seu link de referência ou postando recursos que sejam aprovados. Lembrando que é tudo gratuito, mas é preciso cumprir essas etapas para obter acesso.

Para facilitar: a chave para alguns recursos está na aba "Visão Geral", no final do conteúdo, e a versão mais recente pode ser encontrada na aba de atualizações. Porém, o acesso à chave também exige pontos ou a postagem de um recurso.

exemplo da Visão geral:

1727557309292.png

1727554635608.png

Das atualizações:
1727554714495.png
 
Última edição:

Freitas

Membro conhecido
Membro da equipe
Administrador
Entrou
26 Abril 2021
Mensagens
137
Pontos de reação
108
Pontos
565
Localização
Brasil

Integração Whaticket, Facebook e Instagram - HUB NotificaMe em Ação!​

Git​

Node​

Xammp​

Ngrok​

download

Whaticket + Facebook + Instagram​

2024​

Passo a Passo​


BACKEND​

package.json​

Código:
{
  "name": "backend",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "start": "nodemon dist/server.js",
    "dev:server": "ts-node-dev --respawn --transpile-only --ignore node_modules src/server.ts",
    "pretest": "NODE_ENV=test sequelize db:migrate && NODE_ENV=test sequelize db:seed:all",
    "test": "NODE_ENV=test jest",
    "posttest": "NODE_ENV=test sequelize db:migrate:undo:all"
  },
  "author": "",
  "license": "MIT",
  "dependencies": {
    "@ffmpeg-installer/ffmpeg": "^1.1.0",
    "@sentry/node": "^5.29.2",
    "@types/mime-types": "^2.1.4",
    "@types/pino": "^6.3.4",
    "axios": "^1.7.7",
    "bcryptjs": "^2.4.3",
    "cookie-parser": "^1.4.5",
    "cors": "^2.8.5",
    "date-fns": "^2.16.1",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "express-async-errors": "^3.1.1",
    "file-type": "^19.4.1",
    "fluent-ffmpeg": "^2.1.3",
    "http-graceful-shutdown": "^2.3.2",
    "jsonwebtoken": "^8.5.1",
    "mime": "^4.0.4",
    "mime-types": "^2.1.35",
    "multer": "^1.4.2",
    "mustache": "^4.2.0",
    "mysql2": "^2.2.5",
    "notificamehubsdk": "^0.0.19",
    "pg": "^8.4.1",
    "pino": "^6.9.0",
    "pino-pretty": "~4.7.1",
    "qrcode-terminal": "^0.12.0",
    "reflect-metadata": "^0.1.13",
    "sequelize": "^5.22.3",
    "sequelize-cli": "^5.5.1",
    "sequelize-typescript": "^1.1.0",
    "socket.io": "^3.0.5",
    "uuid": "^8.3.2",
    "whatsapp-web.js": "^1.23.0",
    "yup": "^0.32.8"
  },
  "devDependencies": {
    "@types/bcryptjs": "^2.4.2",
    "@types/bluebird": "^3.5.32",
    "@types/cookie-parser": "^1.4.2",
    "@types/cors": "^2.8.7",
    "@types/express": "^4.17.13",
    "@types/factory-girl": "^5.0.2",
    "@types/faker": "^5.1.3",
    "@types/fluent-ffmpeg": "^2.1.26",
    "@types/jest": "^26.0.15",
    "@types/jsonwebtoken": "^8.5.0",
    "@types/multer": "^1.4.4",
    "@types/mustache": "^4.1.2",
    "@types/node": "^14.11.8",
    "@types/pino-pretty": "~4.7.1",
    "@types/supertest": "^2.0.10",
    "@types/uuid": "^8.3.3",
    "@types/validator": "^13.1.0",
    "@types/yup": "^0.29.8",
    "@typescript-eslint/eslint-plugin": "^4.4.0",
    "@typescript-eslint/parser": "^4.4.0",
    "eslint": "^7.10.0",
    "eslint-config-airbnb-base": "^14.2.0",
    "eslint-config-prettier": "^6.12.0",
    "eslint-import-resolver-typescript": "^2.3.0",
    "eslint-plugin-import": "^2.22.1",
    "eslint-plugin-prettier": "^3.1.4",
    "factory-girl": "^5.0.4",
    "faker": "^5.1.0",
    "jest": "^26.6.0",
    "nodemon": "^2.0.4",
    "prettier": "^2.1.2",
    "supertest": "^5.0.0",
    "ts-jest": "^26.4.1",
    "ts-node-dev": "^1.0.0-pre.63",
    "typescript": "4.1.6"
  }
}

DATABASE​

backend\src\database\migrations\20240905070006-create-hubToken-settings.ts​

Código:
import { QueryInterface } from "sequelize";

module.exports = {
  up: (queryInterface: QueryInterface) => {
    return queryInterface.bulkInsert(
      "Settings",
      [
        {
          key: "hubToken",
          value: "hubToken",
          createdAt: new Date(),
          updatedAt: new Date()
        }
      ],
      {}
    );
  },

  down: (queryInterface: QueryInterface) => {
    return queryInterface.bulkDelete("Settings", {});
  }
};

backend\src\database\migrations\20240905140438-add-hub-to-contacts.ts​


Código:
import { QueryInterface, DataTypes } from "sequelize";

module.exports = {
  up: (queryInterface: QueryInterface) => {
    return Promise.all([
      queryInterface.addColumn("Contacts", "messengerId", {
        type: DataTypes.TEXT,
        allowNull: true,
      }),
      queryInterface.addColumn("Contacts", "instagramId", {
        type: DataTypes.TEXT,
        allowNull: true,
      })
    ]);
  },

  down: (queryInterface: QueryInterface) => {
    return Promise.all([
      queryInterface.removeColumn("Contacts", "messengerId"),
      queryInterface.removeColumn("Contacts", "instagramId")
    ]);
  }
};

backend\src\database\migrations\20240905140438-add-type-to-whatsapp.ts​

Código:
import { QueryInterface, DataTypes } from "sequelize";

module.exports = {
  up: (queryInterface: QueryInterface) => {
    return queryInterface.addColumn("Whatsapps", "type", {
      type: DataTypes.TEXT
    });
  },

  down: (queryInterface: QueryInterface) => {
    return queryInterface.removeColumn("Whatsapps", "type");
  }
};

backend\src\database\migrations\20240905140438-change-column-number-to-allownull.ts​


Código:
import { QueryInterface, DataTypes } from "sequelize";

module.exports = {
  up: (queryInterface: QueryInterface) => {
    return queryInterface.changeColumn("Contacts", "number", {
      type: DataTypes.STRING,
      allowNull: true,
      unique: true
    });
  },

  down: (queryInterface: QueryInterface) => {
    return queryInterface.changeColumn("Contacts", "number", {
      type: DataTypes.STRING,
      allowNull: false,
      unique: true
    });
  }
};

MODELS​

backend\src\models\Contact.ts​


Código:
import {
  Table,
  Column,
  CreatedAt,
  UpdatedAt,
  Model,
  PrimaryKey,
  AutoIncrement,
  AllowNull,
  Unique,
  Default,
  HasMany
} from "sequelize-typescript";
import ContactCustomField from "./ContactCustomField";
import Ticket from "./Ticket";

@Table
class Contact extends Model<Contact> {
  @PrimaryKey
  @AutoIncrement
  @Column
  id: number;

  @Column
  name: string;

  @AllowNull(true)
  @Unique
  @Column
  number: string;

  @AllowNull(false)
  @Default("")
  @Column
  email: string;

  @Column
  profilePicUrl: string;

  @Default(false)
  @Column
  isGroup: boolean;

  @CreatedAt
  createdAt: Date;

  @UpdatedAt
  updatedAt: Date;

  @HasMany(() => Ticket)
  tickets: Ticket[];

  @Column
  messengerId: string;

  @Column
  instagramId: string;

  @HasMany(() => ContactCustomField)
  extraInfo: ContactCustomField[];
}

export default Contact;

backend\src\models\Whatsapp.ts​


Código:
import {
  Table,
  Column,
  CreatedAt,
  UpdatedAt,
  Model,
  DataType,
  PrimaryKey,
  AutoIncrement,
  Default,
  AllowNull,
  HasMany,
  Unique,
  BelongsToMany
} from "sequelize-typescript";
import Queue from "./Queue";
import Ticket from "./Ticket";
import WhatsappQueue from "./WhatsappQueue";

@Table
class Whatsapp extends Model<Whatsapp> {
  @PrimaryKey
  @AutoIncrement
  @Column
  id: number;

  @AllowNull
  @Unique
  @Column(DataType.TEXT)
  name: string;

  @Column(DataType.TEXT)
  session: string;

  @Column(DataType.TEXT)
  qrcode: string;

  @Column
  status: string;

  @Column
  battery: string;

  @Column
  plugged: boolean;

  @Column
  retries: number;

  @Column(DataType.TEXT)
  greetingMessage: string;

  @Column(DataType.TEXT)
  farewellMessage: string;

  @Column
  type: string;

  @Default(false)
  @AllowNull
  @Column
  isDefault: boolean;

  @CreatedAt
  createdAt: Date;

  @UpdatedAt
  updatedAt: Date;

  @HasMany(() => Ticket)
  tickets: Ticket[];

  @BelongsToMany(() => Queue, () => WhatsappQueue)
  queues: Array<Queue & { WhatsappQueue: WhatsappQueue }>;

  @HasMany(() => WhatsappQueue)
  whatsappQueues: WhatsappQueue[];
}

export default Whatsapp;

HELPERS​

backend\src\helpers\ConvertMp3ToMp4.ts​


Código:
import ffmpeg from "fluent-ffmpeg";
import { path as ffmpegPath } from "@ffmpeg-installer/ffmpeg";
import fs from "fs";

// CONVERTER MP3 PARA MP4
const convertMp3ToMp4 = (input: string, outputMP4: string): Promise<void> => {
  return new Promise((resolve, reject) => {
    ffmpeg.setFfmpegPath(ffmpegPath);

    if (!fs.existsSync(input)) {
      const errorMsg = `Input file does not exist: ${input}`;
      console.error(errorMsg);
      return reject(new Error(errorMsg));
    }

    ffmpeg(input)
      .inputFormat("mp3")
      .output(outputMP4)
      .outputFormat("mp4")
      .on("start", (commandLine) => {
      })
      .on("error", (error: Error) => {
        reject(error);
      })
      .on("progress", (progress) => {
        console.log(`Processing...`);
      })
      .on("end", () => {
        console.log("Transcoding succeeded !");
        resolve();
      })
      .run();
  });
};

export { convertMp3ToMp4 };

backend\src\helpers\setChannelHubWebhook.ts​


Código:
import Whatsapp from "../models/Whatsapp";
import { IChannel } from "../controllers/ChannelHubController";
import { showHubToken } from "./showHubToken";
const {
  Client,
  MessageSubscription
} = require("notificamehubsdk");
require("dotenv").config();

export const setChannelWebhook = async (
  whatsapp: IChannel | any,
  whatsappId: string
) => {
  const notificameHubToken = await showHubToken();

  const client = new Client(notificameHubToken);

  const url = `https://1bf1-2804-3d34-5009-5f01-00-2.ngrok-free.app/hub-webhook/${whatsapp.qrcode}`;

  const subscription = new MessageSubscription(
    {
      url
    },
    {
      channel: whatsapp.qrcode
    }
  );

  // client
  // .updateSubscription("subscription-identifier", subscription)
  client
    .createSubscription(subscription)
    .then((response: any) => {
      console.log("Webhook subscribed:", response);
    })
    .catch((error: any) => {
      console.log("Error:", error);
    });

  await Whatsapp.update(
    {
      status: "CONNECTED"
    },
    {
      where: {
        id: whatsappId
      }
    }
  );
};

backend\src\helpers\showHubToken.ts​


Código:
import Setting from "../models/Setting";

export const showHubToken = async (): Promise<string | any> => {
  const notificameHubToken = await Setting.findOne({
    where: {
      key: "hubToken"
    }
  });

  if (!notificameHubToken) {
    throw new Error("Notificame Hub token not found");
  }

  if(notificameHubToken) {
    return notificameHubToken.value;
  }
};

backend\src\helpers\downloadHubFiles.ts​


Código:
import axios from "axios";
import { extname, join } from "path";
import { writeFile } from "fs/promises";
import mime from "mime-types";

export const downloadFiles = async (url: string) => {
  try {
    const { data } = await axios.get(url, {
      responseType: "arraybuffer"
    });

    const type = url.split("?")[0].split(".").pop();

    const filename = `${new Date().getTime()}.${type}`;

    const filePath = `${__dirname}/../../public/${filename}`;

    await writeFile(
      join(__dirname, "..", "..", "public", filename),
      data,
      "base64"
    );

    // const fileTypeResult = await fileType.fromBuffer(data);
    const mimeType = mime.lookup(filePath);
    const extension = extname(filePath);
    const originalname = url.split("/").pop();

    const media = {
      mimeType,
      extension,
      filename,
      data,
      originalname
    };

    return media;
  } catch (error) {
    console.error("Erro ao processar a requisição:", error);
    throw error; // Lança o erro para quem chama a função
  }
};

SERVICES​

backend\src\services\ContactServices\UpdateContactService.ts​

Código:
import AppError from "../../errors/AppError";
import Contact from "../../models/Contact";
import ContactCustomField from "../../models/ContactCustomField";

interface ExtraInfo {
  id?: number;
  name: string;
  value: string;
}
interface ContactData {
  email?: string;
  number?: string;
  name?: string;
  extraInfo?: ExtraInfo[];
}

interface Request {
  contactData: ContactData;
  contactId: string;
}

const UpdateContactService = async ({
  contactData,
  contactId
}: Request): Promise<Contact> => {
  const { email, name, number, extraInfo } = contactData;

  const contact = await Contact.findOne({
    where: { id: contactId },
    attributes: ["id", "name", "number", "profilePicUrl", "messengerId", "instagramId"],
    include: ["extraInfo"]
  });

  if (!contact) {
    throw new AppError("ERR_NO_CONTACT_FOUND", 404);
  }

  if (extraInfo) {
    await Promise.all(
      extraInfo.map(async info => {
        await ContactCustomField.upsert({ ...info, contactId: contact.id });
      })
    );

    await Promise.all(
      contact.extraInfo.map(async oldInfo => {
        const stillExists = extraInfo.findIndex(info => info.id === oldInfo.id);

        if (stillExists === -1) {
          await ContactCustomField.destroy({ where: { id: oldInfo.id } });
        }
      })
    );
  }

  await contact.update({
    name,
    number,
    email
  });

  await contact.reload({
    attributes: ["id", "name", "number", "profilePicUrl", "messengerId", "instagramId"],
    include: ["extraInfo"]
  });

  return contact;
};

export default UpdateContactService;

backend\src\services\HubServices\CreateHubChannelsService.ts​


Código:
import Whatsapp from "../../models/Whatsapp";
import { IChannel } from "../../controllers/ChannelHubController";
import { getIO } from "../../libs/socket";


interface Request {
  channels: IChannel[];
}

interface Response {
  whatsapps: Whatsapp[];
}

const CreateChannelsService = async ({
  channels
}: Request): Promise<Response> => {

  channels = channels.map(channel => {
    return {
      ...channel,
      type: channel.channel,
      qrcode: channel.id,
      status: "CONNECTED"
    };
  });

  const whatsapps = await Whatsapp.bulkCreate(channels);

  // for(const whatsapp of whatsapps){
  //   const connection = await Whatsapp.findOne({
  //     where: { qrcode: whatsapp.id }
  //   });
  //   const io = getIO();
  //   io.emit("whatsapp", {
  //     action: "update",
  //     connection
  //   });
  // }

  return { whatsapps };
};

export default CreateChannelsService;

backend\src\services\HubServices\CreateHubMessageService.ts​


Código:
import { getIO } from "../../libs/socket";
import Message from "../../models/Message";
import Ticket from "../../models/Ticket";
import Whatsapp from "../../models/Whatsapp";

interface MessageData {
  id: string;
  contactId: number;
  body: string;
  ticketId: number;
  fromMe: boolean;
  fileName?: string;
  mediaType?: string;
  originalName?: string;
}

const CreateMessageService = async (
  messageData: MessageData
): Promise<Message | any> => {
  // console.log("creating message");
  // console.log({
  //   messageData
  // });

  const {
    id,
    contactId,
    body,
    ticketId,
    fromMe,
    fileName,
    mediaType,
    originalName
  } = messageData;

  if ((!body || body === "") && (!fileName || fileName === "")) {
    return;
  }

  const data: any = {
    id,
    contactId,
    body,
    ticketId,
    fromMe,
    ack: 2
  };

  if (fileName) {
    data.mediaUrl = fileName;
    data.mediaType = mediaType === "photo" ? "image" : mediaType;
    data.body = data.mediaUrl;
  }

  // console.log({
  //   creatingMediaMessageData: data
  // });

  try {
    const newMessage = await Message.create(data);

    // await newMessage.reload({
    //   include: [
    //     {
    //       association: "ticket",
    //     }
    //   ]
    // });

    const message = await Message.findByPk(messageData.id, {
      include: [
        "contact",
        {
          model: Ticket,
          as: "ticket",
          include: [
            "contact", "queue",
            {
              model: Whatsapp,
              as: "whatsapp",
              attributes: ["name"]
            }
          ]
        },
        {
          model: Message,
          as: "quotedMsg",
          include: ["contact"]
        }
      ]
    });

    if(message){
      const io = getIO();
      io.to(message.ticketId.toString())
        .to(message.ticket.status)
        .to("notification")
        .emit("appMessage", {
          action: "create",
          message,
          ticket: message.ticket,
          contact: message.ticket.contact
        });
      }

    return newMessage;
  } catch (error) {
    console.log(error);
  }

};

export default CreateMessageService;

backend\src\services\HubServices\CreateHubTicketService.ts​


Código:
import AppError from "../../errors/AppError";
import CheckContactOpenTickets from "../../helpers/CheckContactOpenTickets";
import Ticket from "../../models/Ticket";
import User from "../../models/User";
import Whatsapp from "../../models/Whatsapp";
import ShowContactService from "../ContactServices/ShowContactService";

interface Request {
  contactId: number;
  status: string;
  userId: number;
  queueId ?: number;
  channel: string;
}

const CreateTicketService = async ({
  contactId,
  status,
  userId,
  queueId,
  channel
}: Request): Promise<Ticket> => {

  let connectionType

  if(channel === 'instagram' || channel === 'facebook') {
    connectionType = 'facebook'
  }

  console.log('channel', channel)
  console.log('connectionType', connectionType)

  const connection = await Whatsapp.findOne({
    where: { type: connectionType! }
  });

  if (!connection) {
    throw new Error("Connection id not found");
  }

  await CheckContactOpenTickets(contactId, connection.id);

  const { isGroup } = await ShowContactService(contactId);

  if(queueId === undefined) {
    const user = await User.findByPk(userId, { include: ["queues"]});
    queueId = user?.queues.length === 1 ? user.queues[0].id : undefined;
  }

  const newTicket = await Ticket.create({
    status,
    lastMessage: null,
    contactId,
    isGroup,
    whatsappId: connection.id
  });

  const ticket = await Ticket.findByPk(newTicket.id, { include: ["contact"] });

  if (!ticket) {
    throw new AppError("ERR_CREATING_TICKET");
  }

  return ticket;
};

export default CreateTicketService;

backend\src\services\HubServices\CreateOrUpdateHubTicketService.ts​


Código:
import { Op } from "sequelize";
import Ticket from "../../models/Ticket";
import Whatsapp from "../../models/Whatsapp";
import { IContent } from "./HubMessageListener";
import { getIO } from "../../libs/socket";

interface TicketData {
  contactId: number;
  channel: string;
  contents: IContent[];
  connection: Whatsapp;
}

const CreateOrUpdateTicketService = async (
  ticketData: TicketData
): Promise<Ticket> => {

  const { contactId, channel, contents, connection } = ticketData;
  const io = getIO();

  const ticketExists = await Ticket.findOne({
    where: {
      contactId,
      channel,
      whatsappId: connection.id,
    }
  });

  if (ticketExists) {

    let newStatus = ticketExists.status;
    let newQueueId = ticketExists.queueId;

    if (ticketExists.status === "closed") {
      newStatus = "pending";
    }

    await ticketExists.update({
      lastMessage: contents[0].text,
      status: newStatus,
      queueId: newQueueId
    });

    await ticketExists.reload({
      include: [
        {
          association: "contact"
        },
        {
          association: "user"
        },
        {
          association: "queue"
        },
        {
          association: "tags"
        },
        {
          association: "whatsapp"
        }
      ]
    });

    return ticketExists;
  }

  const newTicket = await Ticket.create({
    status: "pending",
    channel,
    lastMessage: contents[0].text,
    contactId,
    whatsappId: connection.id
  });

  await newTicket.reload({
    include: [
      {
        association: "contact"
      },
      {
        association: "user"
      },
      {
        association: "whatsapp"
      }
    ]
  });

  return newTicket;
};

export default CreateOrUpdateTicketService;

backend\src\services\HubServices\FindOrCreateHubContactService.ts​

Código:
import Contact from "../../models/Contact";
import Whatsapp from "../../models/Whatsapp";

interface HubContact {
  name: string;
  firstName: string;
  lastName: string;
  picture: string;
  from: string;
  whatsapp?: Whatsapp;
  channel: string;
}

const FindOrCreateContactService = async (
  contact: HubContact
): Promise<Contact> => {
  const { name, picture, firstName, lastName, from, channel } = contact;

  console.log('contact', contact)
  let numberFb
  let numberIg
  let contactExists

  if(channel === 'facebook'){
    numberFb = from
    contactExists = await Contact.findOne({
      where: {
        messengerId: from,
      }
    });
  }

  if(channel === 'instagram'){
    numberIg = from
    contactExists = await Contact.findOne({
      where: {
        instagramId: from
      }
    });
  }

  if (contactExists) {
    await contactExists.update({ name: name || firstName || 'Name Unavailable' , firstName, lastName, profilePicUrl: picture })
    return contactExists;
  }

  const newContact = await Contact.create({
    name: name || firstName || 'Name Unavailable',
    number: null,
    profilePicUrl: picture,
    messengerId: numberFb || null,
    instagramId: numberIg || null
  });

  return newContact;
};

export default FindOrCreateContactService;

backend\src\services\HubServices\HubMessageListener.ts​

Código:
import Whatsapp from "../../models/Whatsapp";
import { downloadFiles } from "../../helpers/downloadHubFiles";
import CreateMessageService from "./CreateHubMessageService";
import CreateOrUpdateTicketService from "./CreateOrUpdateHubTicketService";
import FindOrCreateContactService from "./FindOrCreateHubContactService";
import { UpdateMessageAck } from "./UpdateMessageHubAck";
import FindOrCreateTicketService from "../TicketServices/FindOrCreateTicketService";

export interface HubInMessage {
  type: "MESSAGE";
  id: string;
  timestamp: string;
  subscriptionId: string;
  channel: "telegram" | "whatsapp" | "facebook" | "instagram" | "sms" | "email";
  direction: "IN";
  message: {
    id: string;
    from: string;
    to: string;
    direction: "IN";
    channel:
      | "telegram"
      | "whatsapp"
      | "facebook"
      | "instagram"
      | "sms"
      | "email";
    visitor: {
      name: string;
      firstName: string;
      lastName: string;
      picture: string;
    };
    contents: IContent[];
    timestamp: string;
  };
}

export interface IContent {
  type: "text" | "image" | "audio" | "video" | "file" | "location";
  text?: string;
  url?: string;
  fileUrl?: string;
  latitude?: number;
  longitude?: number;
  filename?: string;
  fileSize?: number;
  fileMimeType?: string;
}

export interface HubConfirmationSentMessage {
  type: "MESSAGE_STATUS";
  timestamp: string;
  subscriptionId: string;
  channel: "telegram" | "whatsapp" | "facebook" | "instagram" | "sms" | "email";
  messageId: string;
  contentIndex: number;
  messageStatus: {
    timestamp: string;
    code: "SENT" | "REJECTED";
    description: string;
  };
}

const verifySentMessageStatus = (message: HubConfirmationSentMessage) => {
  const {
    messageStatus: { code }
  } = message;

  const isMessageSent = code === "SENT";

  if (isMessageSent) {
    return true;
  }

  return false;
};

const HubMessageListener = async (
  message: any | HubInMessage | HubConfirmationSentMessage,
  whatsapp: Whatsapp,
  medias: Express.Multer.File[]
) => {
  console.log("HubMessageListener", message);
  console.log("contents", message.message.contents);

  if(message.direction === 'IN'){
    message.fromMe = false
  }

  const ignoreEvent = message.direction === 'OUT'
  if (ignoreEvent) {
    return;
  }

  const isMessageFromMe = message.type === "MESSAGE_STATUS";

  if (isMessageFromMe) {
    const isMessageSent = verifySentMessageStatus(
      message as HubConfirmationSentMessage
    );

    if (isMessageSent) {
      console.log("HubMessageListener: message sent");
      UpdateMessageAck(message.messageId);
    } else {
      console.log(
        "HubMessageListener: message not sent",
        message.messageStatus.code,
        message.messageStatus.description
      );
    }

    return;
  }

  const {
    message: { id, from, channel, contents, visitor }
  } = message as HubInMessage;

  try {
    const contact = await FindOrCreateContactService({
      ...visitor,
      from,
      whatsapp,
      channel
    });

    const unreadMessages = 1

    const ticket = await FindOrCreateTicketService(
      contact,
      whatsapp.id!,
      unreadMessages,
    );

    // const ticket = await CreateOrUpdateTicketService({
    //   contactId: contact.id,
    //   channel,
    //   contents,
    //   whatsapp
    // });

    if (contents[0]?.type === "text") {
      await CreateMessageService({
        id,
        contactId: contact.id,
        body: contents[0].text || "",
        ticketId: ticket.id,
        fromMe: false,
      });
    } else if (contents[0]?.fileUrl) {
      const media = await downloadFiles(contents[0].fileUrl);

      if (typeof media.mimeType === "string") {
        await CreateMessageService({
          id,
          contactId: contact.id,
          body: contents[0].text || '',
          ticketId: ticket.id,
          fromMe: false,
          fileName: `${media.filename}`,
          mediaType: media.mimeType.split("/")[0],
          originalName: media.originalname
        });
      }

    }
  } catch (error: any) {
    console.log(error);
  }
};

export default HubMessageListener;

backend\src\services\HubServices\ListHubChannels.ts​


Código:
import { showHubToken } from "../../helpers/showHubToken";
const { Client } = require("notificamehubsdk");
require("dotenv").config();

const ListChannels = async () => {
  try {
    const notificameHubToken = await showHubToken();

    if (!notificameHubToken) {
      throw new Error("NOTIFICAMEHUB_TOKEN_NOT_FOUND");
    }

    const client = new Client(notificameHubToken);

    const response = await client.listChannels();
    console.log("Response:", response);
    return response;
  } catch (error) {
    throw new Error('Error');
  }
};

export default ListChannels;

backend\src\services\HubServices\SendMediaMessageHubService.ts​


Código:
require("dotenv").config();
const { Client, FileContent } = require("notificamehubsdk");
import Contact from "../../models/Contact";
import CreateMessageService from "./CreateHubMessageService";
import { showHubToken } from "../../helpers/showHubToken";
import { convertMp3ToMp4 } from "../../helpers/ConvertMp3ToMp4";

export const SendMediaMessageService = async (
  media: Express.Multer.File,
  message: string,
  ticketId: number,
  contact: Contact,
  connection: any
) => {
  const notificameHubToken = await showHubToken();

  const client = new Client(notificameHubToken);

  let channelClient
  let contactNumber
  let type
  let mediaUrl

  if(contact.messengerId && !contact.instagramId){
    contactNumber = contact.messengerId
    type = 'facebook'
    channelClient = client.setChannel(type);
  }
  if(!contact.messengerId && contact.instagramId){
    contactNumber = contact.instagramId
    type = 'instagram'
    channelClient = client.setChannel(type);
  }

  message = message.replace(/\n/g, " ");

  const backendUrl = 'https://1bf1-2804-3d34-5009-5f01-00-2.ngrok-free.app';

  const filename = encodeURIComponent(media.filename);
  mediaUrl = `${backendUrl}/public/${filename}`;

  if (media.mimetype.includes("image")) {
    if (type === "telegram") {
      media.mimetype = "photo";
    } else {
      media.mimetype = "image";
    }
  } else if (
    (type === "telegram" || type === "facebook") &&
    media.mimetype.includes("audio")
  ) {
    media.mimetype = "audio";
  } else if (
    (type === "telegram" || type === "facebook") &&
    media.mimetype.includes("video")
  ) {
    media.mimetype = "video";
  } else if (type === "telegram" || type === "facebook") {
    media.mimetype = "file";
  }

  try {

    if (media.originalname.includes('.mp3') && type === 'instagram') {
      const inputPath = media.path;
      const outputMP4Path = `${media.destination}/${media.filename.split('.')[0]}.mp4`;
      try {
        await convertMp3ToMp4(inputPath, outputMP4Path);
        media.filename = outputMP4Path.split('/').pop() ?? 'default.mp4';
        mediaUrl = `${backendUrl}/public/${media.filename}`;
        media.originalname = media.filename
        media.mimetype = 'audio'
      } catch(e){

      }
    }

    if (media.originalname.includes('.mp3') && type === 'facebook') {
      mediaUrl = `${backendUrl}/public/${media.filename}`;
      media.originalname = media.filename
      media.mimetype = 'audio'
    }

    const content = new FileContent(
      mediaUrl,
      media.mimetype,
      media.originalname,
      media.originalname
    );

    console.log({
      token: connection.qrcode,
      number: contactNumber,
      content,
      message
    });

    let response = await channelClient.sendMessage(
      connection.qrcode,
      contactNumber,
      content
    );
    console.log("response:", response);


    let data: any;

    try {
      const jsonStart = response.indexOf("{");
      const jsonResponse = response.substring(jsonStart);
      data = JSON.parse(jsonResponse);
    } catch (error) {
      data = response;
    }

    const newMessage = await CreateMessageService({
      id: data.id,
      contactId: contact.id,
      body: message,
      ticketId,
      fromMe: true,
      fileName: `${media.filename}`,
      mediaType: media.mimetype.split("/")[0],
      originalName: media.originalname
    });

    return newMessage;
  } catch (error) {
    console.log("Error:", error);
  }
};

backend\src\services\HubServices\SendTextMessageHubService.ts​


Código:
require("dotenv").config();
const { Client, TextContent } = require("notificamehubsdk");
import Contact from "../../models/Contact";
import CreateMessageService from "./CreateHubMessageService";
import { showHubToken } from "../../helpers/showHubToken";

export const SendTextMessageService = async (
  message: string,
  ticketId: number,
  contact: Contact,
  connection: any
) => {
  const notificameHubToken = await showHubToken();

  const client = new Client(notificameHubToken);

  let channelClient

  message = message.replace(/\n/g, " ");

  const content = new TextContent(message);

  let contactNumber

  if(contact.messengerId && !contact.instagramId){
    contactNumber = contact.messengerId
    channelClient = client.setChannel('facebook');
  }
  if(!contact.messengerId && contact.instagramId){
    contactNumber = contact.instagramId
    channelClient = client.setChannel('instagram');
  }

  try {
    console.log({
      token: connection.qrcode,
      number: contactNumber,
      content,
      message
    });

    let response = await channelClient.sendMessage(
      connection.qrcode,
      contactNumber,
      content
    );

    console.log("response:", response);

    let data: any;

    try {
      const jsonStart = response.indexOf("{");
      const jsonResponse = response.substring(jsonStart);
      data = JSON.parse(jsonResponse);
    } catch (error) {
      data = response;
    }

    const newMessage = await CreateMessageService({
      id: data.id,
      contactId: contact.id,
      body: message,
      ticketId,
      fromMe: true
    });

    return newMessage;
  } catch (error) {
    console.log("Error:", error);
  }
};

backend\src\services\HubServices\UpdateMessageHubAck.ts​


Código:
import Message from "../../models/Message";

export const UpdateMessageAck = async (messageId: string): Promise<void> => {
  const message = await Message.findOne({
    where: {
      id: messageId
    }
  });

  if (!message) {
    return;
  }

  await message.update({
    ack: 3
  });
};

backend\src\services\TicketServices\ListTicketsService.ts​


Código:
import { Op, fn, where, col, Filterable, Includeable } from "sequelize";
import { startOfDay, endOfDay, parseISO } from "date-fns";

import Ticket from "../../models/Ticket";
import Contact from "../../models/Contact";
import Message from "../../models/Message";
import Queue from "../../models/Queue";
import ShowUserService from "../UserServices/ShowUserService";
import Whatsapp from "../../models/Whatsapp";

interface Request {
  searchParam?: string;
  pageNumber?: string;
  status?: string;
  date?: string;
  showAll?: string;
  userId: string;
  withUnreadMessages?: string;
  queueIds: number[];
}

interface Response {
  tickets: Ticket[];
  count: number;
  hasMore: boolean;
}

const ListTicketsService = async ({
  searchParam = "",
  pageNumber = "1",
  queueIds,
  status,
  date,
  showAll,
  userId,
  withUnreadMessages
}: Request): Promise<Response> => {
  let whereCondition: Filterable["where"] = {
    [Op.or]: [{ userId }, { status: "pending" }],
    queueId: { [Op.or]: [queueIds, null] }
  };
  let includeCondition: Includeable[];

  includeCondition = [
    {
      model: Contact,
      as: "contact",
      attributes: ["id", "name", "number", "profilePicUrl", "messengerId", "instagramId"]
    },
    {
      model: Queue,
      as: "queue",
      attributes: ["id", "name", "color"]
    },
    {
      model: Whatsapp,
      as: "whatsapp",
      attributes: ["name"]
    }
  ];

  if (showAll === "true") {
    whereCondition = { queueId: { [Op.or]: [queueIds, null] } };
  }

  if (status) {
    whereCondition = {
      ...whereCondition,
      status
    };
  }

  if (searchParam) {
    const sanitizedSearchParam = searchParam.toLocaleLowerCase().trim();

    includeCondition = [
      ...includeCondition,
      {
        model: Message,
        as: "messages",
        attributes: ["id", "body"],
        where: {
          body: where(
            fn("LOWER", col("body")),
            "LIKE",
            `%${sanitizedSearchParam}%`
          )
        },
        required: false,
        duplicating: false
      }
    ];

    whereCondition = {
      ...whereCondition,
      [Op.or]: [
        {
          "$contact.name$": where(
            fn("LOWER", col("contact.name")),
            "LIKE",
            `%${sanitizedSearchParam}%`
          )
        },
        { "$contact.number$": { [Op.like]: `%${sanitizedSearchParam}%` } },
        {
          "$message.body$": where(
            fn("LOWER", col("body")),
            "LIKE",
            `%${sanitizedSearchParam}%`
          )
        }
      ]
    };
  }

  if (date) {
    whereCondition = {
      createdAt: {
        [Op.between]: [+startOfDay(parseISO(date)), +endOfDay(parseISO(date))]
      }
    };
  }

  if (withUnreadMessages === "true") {
    const user = await ShowUserService(userId);
    const userQueueIds = user.queues.map(queue => queue.id);

    whereCondition = {
      [Op.or]: [{ userId }, { status: "pending" }],
      queueId: { [Op.or]: [userQueueIds, null] },
      unreadMessages: { [Op.gt]: 0 }
    };
  }

  const limit = 40;
  const offset = limit * (+pageNumber - 1);

  const { count, rows: tickets } = await Ticket.findAndCountAll({
    where: whereCondition,
    include: includeCondition,
    distinct: true,
    limit,
    offset,
    order: [["updatedAt", "DESC"]]
  });

  const hasMore = count > offset + tickets.length;

  return {
    tickets,
    count,
    hasMore
  };
};

export default ListTicketsService;

backend\src\services\TicketServices\ShowTicketService.ts​


Código:
import Ticket from "../../models/Ticket";
import AppError from "../../errors/AppError";
import Contact from "../../models/Contact";
import User from "../../models/User";
import Queue from "../../models/Queue";
import Whatsapp from "../../models/Whatsapp";

const ShowTicketService = async (id: string | number): Promise<Ticket> => {
  const ticket = await Ticket.findByPk(id, {
    include: [
      {
        model: Contact,
        as: "contact",
        attributes: ["id", "name", "number", "profilePicUrl", "messengerId", "instagramId"],
        include: ["extraInfo"]
      },
      {
        model: User,
        as: "user",
        attributes: ["id", "name"]
      },
      {
        model: Queue,
        as: "queue",
        attributes: ["id", "name", "color"]
      },
      {
        model: Whatsapp,
        as: "whatsapp",
        attributes: ["name", "type"]
      }
    ]
  });

  if (!ticket) {
    throw new AppError("ERR_NO_TICKET_FOUND", 404);
  }

  return ticket;
};

export default ShowTicketService;

backend\src\services\WbotServices\StartAllWhatsAppsSessions.ts​


Código:
import { setChannelWebhook } from "../../helpers/setChannelHubWebhook";
import ListWhatsAppsService from "../WhatsappService/ListWhatsAppsService";
import { StartWhatsAppSession } from "./StartWhatsAppSession";

export const StartAllWhatsAppsSessions = async (): Promise<void> => {
  const whatsapps = await ListWhatsAppsService();
  if (whatsapps.length > 0) {
    whatsapps.forEach(whatsapp => {
      if(whatsapp.type !== null) {
        setChannelWebhook(whatsapp, whatsapp.id.toString());
      } else {
        StartWhatsAppSession(whatsapp);
      }
    });
  }
};

CONTROLLERS​

backend\src\controllers\ChannelHubController.ts​

Código:
import { Request, Response } from "express";
import CreateChannelsService from "../services/HubServices/CreateHubChannelsService";
import { setChannelWebhook } from "../helpers/setChannelHubWebhook";
import { getIO } from "../libs/socket";
import ListChannels from "../services/HubServices/ListHubChannels";

export interface IChannel {
  name: string;
  status?: string;
  isDefault?: boolean;
  qrcode?: string;
  type?: string;
  channel?: string;
  id?:string;
}

export const store = async (req: Request, res: Response): Promise<Response> => {

  const { whatsapps } = await CreateChannelsService(req.body);

  whatsapps.forEach(whatsapp => {
    setTimeout(() => {
      setChannelWebhook(whatsapp, whatsapp.id.toString());
    }, 2000);
  });

  return res.status(200).json(whatsapps);
};

export const index = async (req: Request, res: Response): Promise<Response> => {

  try {
    const channels = await ListChannels();
    return res.status(200).json(channels);
  } catch (error) {
    return res.status(500).json({ error: error });
  }
};

backend\src\controllers\ContactController.ts​


Código:
import * as Yup from "yup";
import { Request, Response } from "express";
import { getIO } from "../libs/socket";

import ListContactsService from "../services/ContactServices/ListContactsService";
import CreateContactService from "../services/ContactServices/CreateContactService";
import ShowContactService from "../services/ContactServices/ShowContactService";
import UpdateContactService from "../services/ContactServices/UpdateContactService";
import DeleteContactService from "../services/ContactServices/DeleteContactService";

import CheckContactNumber from "../services/WbotServices/CheckNumber"
import CheckIsValidContact from "../services/WbotServices/CheckIsValidContact";
import GetProfilePicUrl from "../services/WbotServices/GetProfilePicUrl";
import AppError from "../errors/AppError";
import GetContactService from "../services/ContactServices/GetContactService";

type IndexQuery = {
  searchParam: string;
  pageNumber: string;
};

type IndexGetContactQuery = {
  name: string;
  number: string;
};

interface ExtraInfo {
  name: string;
  value: string;
}
interface ContactData {
  name: string;
  number: string;
  email?: string;
  messengerId?: string;
  instagramId?: string;
  extraInfo?: ExtraInfo[];
}

export const index = async (req: Request, res: Response): Promise<Response> => {
  const { searchParam, pageNumber } = req.query as IndexQuery;

  const { contacts, count, hasMore } = await ListContactsService({
    searchParam,
    pageNumber
  });

  return res.json({ contacts, count, hasMore });
};

export const getContact = async (req: Request, res: Response): Promise<Response> => {
  const { name, number } = req.body as IndexGetContactQuery;

  const contact = await GetContactService({
    name,
    number
  });

  return res.status(200).json(contact);
};

export const store = async (req: Request, res: Response): Promise<Response> => {
  const newContact: ContactData = req.body;
  newContact.number = newContact.number.replace("-", "").replace(" ", "");

  const schema = Yup.object().shape({
    name: Yup.string().required(),
    number: Yup.string()
      .required()
      .matches(/^\d+$/, "Invalid number format. Only numbers is allowed.")
  });

  try {
    await schema.validate(newContact);
  } catch (err) {
    throw new AppError(err.message);
  }

  await CheckIsValidContact(newContact.number);
  const validNumber : any = await CheckContactNumber(newContact.number)

  const profilePicUrl = await GetProfilePicUrl(validNumber);

  let name = newContact.name
  let number = validNumber
  let email = newContact.email
  let extraInfo = newContact.extraInfo

  const contact = await CreateContactService({
    name,
    number,
    email,
    extraInfo,
    profilePicUrl
  });

  const io = getIO();
  io.emit("contact", {
    action: "create",
    contact
  });

  return res.status(200).json(contact);
};

export const show = async (req: Request, res: Response): Promise<Response> => {
  const { contactId } = req.params;

  const contact = await ShowContactService(contactId);

  return res.status(200).json(contact);
};

export const update = async (
  req: Request,
  res: Response
): Promise<Response> => {
  const contactData: ContactData = req.body;

  const schema = Yup.object().shape({
    name: Yup.string(),
    // number: Yup.string().matches(
    //   /^\d+$/,
    //   "Invalid number format. Only numbers is allowed."
    // )
  });

  try {
    await schema.validate(contactData);
  } catch (err) {
    throw new AppError(err.message);
  }

  if(!contactData.messengerId && !contactData.instagramId){
    await CheckIsValidContact(contactData.number);
  }

  const { contactId } = req.params;

  const contact = await UpdateContactService({ contactData, contactId });

  const io = getIO();
  io.emit("contact", {
    action: "update",
    contact
  });

  return res.status(200).json(contact);
};

export const remove = async (
  req: Request,
  res: Response
): Promise<Response> => {
  const { contactId } = req.params;

  await DeleteContactService(contactId);

  const io = getIO();
  io.emit("contact", {
    action: "delete",
    contactId
  });

  return res.status(200).json({ message: "Contact deleted" });
};

backend\src\controllers\MessageHubController.ts​

Código:
import { Request, Response } from "express";
import Contact from "../models/Contact";
import Ticket from "../models/Ticket";
import { SendTextMessageService } from "../services/HubServices/SendTextMessageHubService";
import Whatsapp from "../models/Whatsapp";
import { SendMediaMessageService } from "../services/HubServices/SendMediaMessageHubService";
import CreateHubTicketService from "../services/HubServices/CreateHubTicketService";
import { getIO } from "../libs/socket";

interface TicketData {
  contactId: number;
  status: string;
  queueId: number;
  userId: number;
  channel: string;
}

export const send = async (req: Request, res: Response): Promise<Response> => {
  const { body: message } = req.body;
  const { ticketId } = req.params;
  const medias = req.files as Express.Multer.File[];

  console.log("sending hub message controller");

  const ticket = await Ticket.findByPk(ticketId, {
    include: [
      {
        model: Contact,
        as: "contact",
        attributes: ["number", "messengerId", "instagramId"]
      },
      {
        model: Whatsapp,
        as: "whatsapp",
        attributes: ["qrcode", "type"]
      }
    ]
  });

  if (!ticket) {
    return res.status(404).json({ message: "Ticket not found" });
  }

  try {
    if (medias) {
      await Promise.all(
        medias.map(async (media: Express.Multer.File) => {
          await SendMediaMessageService(
            media,
            message,
            ticket.id,
            ticket.contact,
            ticket.whatsapp
          );
        })
      );
    } else {
      await SendTextMessageService(
        message,
        ticket.id,
        ticket.contact,
        ticket.whatsapp
      );
    }

    return res.status(200).json({ message: "Message sent" });
  } catch (error) {
    console.log(error);

    return res.status(400).json({ message: error });
  }
};

export const store = async (req: Request, res: Response): Promise<Response> => {
  const { contactId, status, userId, channel }: TicketData = req.body;

  const ticket = await CreateHubTicketService({ contactId, status, userId, channel });

  const io = getIO();
  io.to(ticket.status).emit("ticket", {
    action: "update",
    ticket
  });

  return res.status(200).json(ticket);
};

backend\src\controllers\WebhookHubController.ts​


Código:
import { Request, Response } from "express";
import Whatsapp from "../models/Whatsapp";
import HubMessageListener from "../services/HubServices/HubMessageListener";

export const listen = async (
  req: Request,
  res: Response
): Promise<Response> => {
  console.log("Webhook received");
  const medias = req.files as Express.Multer.File[];
  const { channelId } = req.params;

  const connection = await Whatsapp.findOne({
    where: { qrcode: channelId }
  });

  if (!connection) {
    return res.status(404).json({ message: "Whatsapp channel not found" });
  }

  try {
    await HubMessageListener(req.body, connection, medias);

    return res.status(200).json({ message: "Webhook received" });
  } catch (error) {
    return res.status(400).json({ message: error });
  }
};

ROUTES​

backend\src\routes\hubChannelRoutes.ts​


Código:
import express from "express";

import * as ChannelController from "../controllers/ChannelHubController";
import isAuth from "../middleware/isAuth";

const hubChannelRoutes = express.Router();

hubChannelRoutes.post("/hub-channel/", isAuth, ChannelController.store);
hubChannelRoutes.get("/hub-channel/", isAuth, ChannelController.index);

export default hubChannelRoutes;

backend\src\routes\hubMessageRoutes.ts​


Código:
import express from "express";
import uploadConfig from "../config/upload";

import * as MessageController from "../controllers/MessageHubController";
import isAuth from "../middleware/isAuth";
import multer from "multer";

const hubMessageRoutes = express.Router();
const upload = multer(uploadConfig);

hubMessageRoutes.post(
  "/hub-message/:ticketId",
  isAuth,
  upload.array("medias"),
  MessageController.send
);

hubMessageRoutes.post("/hub-ticket", isAuth, MessageController.store);

export default hubMessageRoutes;

backend\src\routes\hubWebhookRoutes.ts​


Código:
import express from "express";
import uploadConfig from "../config/upload";

import * as WebhookController from "../controllers/WebhookHubController";
import multer from "multer";

const hubWebhookRoutes = express.Router();
const upload = multer(uploadConfig);

hubWebhookRoutes.post(
  "/hub-webhook/:channelId",
  upload.array("medias"),
  WebhookController.listen
);

export default hubWebhookRoutes;

backend\src\routes\index.ts​


Código:
import { Router } from "express";

import userRoutes from "./userRoutes";
import authRoutes from "./authRoutes";
import settingRoutes from "./settingRoutes";
import contactRoutes from "./contactRoutes";
import ticketRoutes from "./ticketRoutes";
import whatsappRoutes from "./whatsappRoutes";
import messageRoutes from "./messageRoutes";
import whatsappSessionRoutes from "./whatsappSessionRoutes";
import queueRoutes from "./queueRoutes";
import quickAnswerRoutes from "./quickAnswerRoutes";
import apiRoutes from "./apiRoutes";
import hubChannelRoutes from "./hubChannelRoutes";
import hubMessageRoutes from "./hubMessageRoutes";
import hubWebhookRoutes from "./hubWebhookRoutes";

const routes = Router();

routes.use(userRoutes);
routes.use("/auth", authRoutes);
routes.use(settingRoutes);
routes.use(contactRoutes);
routes.use(ticketRoutes);
routes.use(whatsappRoutes);
routes.use(messageRoutes);
routes.use(whatsappSessionRoutes);
routes.use(queueRoutes);
routes.use(quickAnswerRoutes);
routes.use(hubChannelRoutes);
routes.use(hubMessageRoutes);
routes.use(hubWebhookRoutes);
routes.use("/api/messages", apiRoutes);

export default routes;

FRONTEND​

COMPONENTS​

frontend\src\components\ContactModal\index.js​

Código:
import React, { useState, useEffect, useRef } from "react";

import * as Yup from "yup";
import { Formik, FieldArray, Form, Field } from "formik";
import { toast } from "react-toastify";

import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from "@material-ui/core/DialogTitle";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
import CircularProgress from "@material-ui/core/CircularProgress";

import { i18n } from "../../translate/i18n";

import api from "../../services/api";
import toastError from "../../errors/toastError";

const useStyles = makeStyles(theme => ({
    root: {
        display: "flex",
        flexWrap: "wrap",
    },
    textField: {
        marginRight: theme.spacing(1),
        flex: 1,
    },

    extraAttr: {
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
    },

    btnWrapper: {
        position: "relative",
    },

    buttonProgress: {
        color: green[500],
        position: "absolute",
        top: "50%",
        left: "50%",
        marginTop: -12,
        marginLeft: -12,
    },
}));

const ContactSchema = Yup.object().shape({
    name: Yup.string()
        .min(2, "Too Short!")
        .max(50, "Too Long!")
        .required("Required"),
    // number: Yup.string().min(8, "Too Short!").max(50, "Too Long!"),
    email: Yup.string().email("Invalid email"),
});

const ContactModal = ({ open, onClose, contactId, initialValues, onSave }) => {
    const classes = useStyles();
    const isMounted = useRef(true);

    const initialState = {
        name: "",
        number: "",
        email: "",
    };

    const [contact, setContact] = useState(initialState);

    useEffect(() => {
        return () => {
            isMounted.current = false;
        };
    }, []);

    useEffect(() => {
        const fetchContact = async () => {
            if (initialValues) {
                setContact(prevState => {
                    return { ...prevState, ...initialValues };
                });
            }

            if (!contactId) return;

            try {
                const { data } = await api.get(`/contacts/${contactId}`);
                if (isMounted.current) {
                    setContact(data);
                }
            } catch (err) {
                toastError(err);
            }
        };

        fetchContact();
    }, [contactId, open, initialValues]);

    const handleClose = () => {
        onClose();
        setContact(initialState);
    };

    const handleSaveContact = async values => {
        try {
            if (contactId) {
                await api.put(`/contacts/${contactId}`, values);
                handleClose();
            } else {
                const { data } = await api.post("/contacts", values);
                if (onSave) {
                    onSave(data);
                }
                handleClose();
            }
            toast.success(i18n.t("contactModal.success"));
        } catch (err) {
            toastError(err);
        }
    };

    return (
        <div className={classes.root}>
            <Dialog open={open} onClose={handleClose} maxWidth="lg" scroll="paper">
                <DialogTitle id="form-dialog-title">
                    {contactId
                        ? `${i18n.t("contactModal.title.edit")}`
                        : `${i18n.t("contactModal.title.add")}`}
                </DialogTitle>
                <Formik
                    initialValues={contact}
                    enableReinitialize={true}
                    validationSchema={ContactSchema}
                    onSubmit={(values, actions) => {
                        setTimeout(() => {
                            handleSaveContact(values);
                            actions.setSubmitting(false);
                        }, 400);
                    }}
                >
                    {({ values, errors, touched, isSubmitting }) => (
                        <Form>
                            <DialogContent dividers>
                                <Typography variant="subtitle1" gutterBottom>
                                    {i18n.t("contactModal.form.mainInfo")}
                                </Typography>
                                <Field
                                    as={TextField}
                                    label={i18n.t("contactModal.form.name")}
                                    name="name"
                                    autoFocus
                                    error={touched.name && Boolean(errors.name)}
                                    helperText={touched.name && errors.name}
                                    variant="outlined"
                                    margin="dense"
                                    className={classes.textField}
                                />
                                <Field
                                    as={TextField}
                                    label={i18n.t("contactModal.form.number")}
                                    name="number"
                                    error={touched.number && Boolean(errors.number)}
                                    helperText={touched.number && errors.number}
                                    placeholder="5513912344321"
                                    variant="outlined"
                                    margin="dense"
                                />
                                <div>
                                    <Field
                                        as={TextField}
                                        label={i18n.t("contactModal.form.email")}
                                        name="email"
                                        error={touched.email && Boolean(errors.email)}
                                        helperText={touched.email && errors.email}
                                        placeholder="Email address"
                                        fullWidth
                                        margin="dense"
                                        variant="outlined"
                                    />
                                </div>
                                <Typography
                                    style={{ marginBottom: 8, marginTop: 12 }}
                                    variant="subtitle1"
                                >
                                    {i18n.t("contactModal.form.extraInfo")}
                                </Typography>

                                <FieldArray name="extraInfo">
                                    {({ push, remove }) => (
                                        <>
                                            {values.extraInfo &&
                                                values.extraInfo.length > 0 &&
                                                values.extraInfo.map((info, index) => (
                                                    <div
                                                        className={classes.extraAttr}
                                                        key={`${index}-info`}
                                                    >
                                                        <Field
                                                            as={TextField}
                                                            label={i18n.t("contactModal.form.extraName")}
                                                            name={`extraInfo[${index}].name`}
                                                            variant="outlined"
                                                            margin="dense"
                                                            className={classes.textField}
                                                        />
                                                        <Field
                                                            as={TextField}
                                                            label={i18n.t("contactModal.form.extraValue")}
                                                            name={`extraInfo[${index}].value`}
                                                            variant="outlined"
                                                            margin="dense"
                                                            className={classes.textField}
                                                        />
                                                        <IconButton
                                                            size="small"
                                                            onClick={() => remove(index)}
                                                        >
                                                            <DeleteOutlineIcon />
                                                        </IconButton>
                                                    </div>
                                                ))}
                                            <div className={classes.extraAttr}>
                                                <Button
                                                    style={{ flex: 1, marginTop: 8 }}
                                                    variant="outlined"
                                                    color="primary"
                                                    onClick={() => push({ name: "", value: "" })}
                                                >
                                                    {`+ ${i18n.t("contactModal.buttons.addExtraInfo")}`}
                                                </Button>
                                            </div>
                                        </>
                                    )}
                                </FieldArray>
                            </DialogContent>
                            <DialogActions>
                                <Button
                                    onClick={handleClose}
                                    color="secondary"
                                    disabled={isSubmitting}
                                    variant="outlined"
                                >
                                    {i18n.t("contactModal.buttons.cancel")}
                                </Button>
                                <Button
                                    type="submit"
                                    color="primary"
                                    disabled={isSubmitting}
                                    variant="contained"
                                    className={classes.btnWrapper}
                                >
                                    {contactId
                                        ? `${i18n.t("contactModal.buttons.okEdit")}`
                                        : `${i18n.t("contactModal.buttons.okAdd")}`}
                                    {isSubmitting && (
                                        <CircularProgress
                                            size={24}
                                            className={classes.buttonProgress}
                                        />
                                    )}
                                </Button>
                            </DialogActions>
                        </Form>
                    )}
                </Formik>
            </Dialog>
        </div>
    );
};

export default ContactModal;

frontend\src\components\MessageInput\index.js​


Código:
import React, { useState, useEffect, useContext, useRef } from "react";
import "emoji-mart/css/emoji-mart.css";
import { useParams } from "react-router-dom";
import { Picker } from "emoji-mart";
import MicRecorder from "mic-recorder-to-mp3";
import clsx from "clsx";

import { makeStyles } from "@material-ui/core/styles";
import Paper from "@material-ui/core/Paper";
import InputBase from "@material-ui/core/InputBase";
import CircularProgress from "@material-ui/core/CircularProgress";
import { green } from "@material-ui/core/colors";
import AttachFileIcon from "@material-ui/icons/AttachFile";
import IconButton from "@material-ui/core/IconButton";
import MoreVert from "@material-ui/icons/MoreVert";
import MoodIcon from "@material-ui/icons/Mood";
import SendIcon from "@material-ui/icons/Send";
import CancelIcon from "@material-ui/icons/Cancel";
import ClearIcon from "@material-ui/icons/Clear";
import MicIcon from "@material-ui/icons/Mic";
import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline";
import HighlightOffIcon from "@material-ui/icons/HighlightOff";
import {
  FormControlLabel,
  Hidden,
  Menu,
  MenuItem,
  Switch,
} from "@material-ui/core";
import ClickAwayListener from "@material-ui/core/ClickAwayListener";

import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import RecordingTimer from "./RecordingTimer";
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
import { AuthContext } from "../../context/Auth/AuthContext";
import { useLocalStorage } from "../../hooks/useLocalStorage";
import toastError from "../../errors/toastError";

const Mp3Recorder = new MicRecorder({ bitRate: 128 });

const useStyles = makeStyles((theme) => ({
  mainWrapper: {
    background: "#eee",
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    borderTop: "1px solid rgba(0, 0, 0, 0.12)",
    [theme.breakpoints.down("sm")]: {
      position: "fixed",
      bottom: 0,
      width: "100%",
    },
  },

  newMessageBox: {
    background: "#eee",
    width: "100%",
    display: "flex",
    padding: "7px",
    alignItems: "center",
  },

  messageInputWrapper: {
    padding: 6,
    marginRight: 7,
    background: "#fff",
    display: "flex",
    borderRadius: 20,
    flex: 1,
    position: "relative",
  },

  messageInput: {
    paddingLeft: 10,
    flex: 1,
    border: "none",
  },

  sendMessageIcons: {
    color: "grey",
  },

  uploadInput: {
    display: "none",
  },

  viewMediaInputWrapper: {
    display: "flex",
    padding: "10px 13px",
    position: "relative",
    justifyContent: "space-between",
    alignItems: "center",
    backgroundColor: "#eee",
    borderTop: "1px solid rgba(0, 0, 0, 0.12)",
  },

  emojiBox: {
    position: "absolute",
    bottom: 63,
    width: 40,
    borderTop: "1px solid #e8e8e8",
  },

  circleLoading: {
    color: green[500],
    opacity: "70%",
    position: "absolute",
    top: "20%",
    left: "50%",
    marginLeft: -12,
  },

  audioLoading: {
    color: green[500],
    opacity: "70%",
  },

  recorderWrapper: {
    display: "flex",
    alignItems: "center",
    alignContent: "middle",
  },

  cancelAudioIcon: {
    color: "red",
  },

  sendAudioIcon: {
    color: "green",
  },

  replyginMsgWrapper: {
    display: "flex",
    width: "100%",
    alignItems: "center",
    justifyContent: "center",
    paddingTop: 8,
    paddingLeft: 73,
    paddingRight: 7,
  },

  replyginMsgContainer: {
    flex: 1,
    marginRight: 5,
    overflowY: "hidden",
    backgroundColor: "rgba(0, 0, 0, 0.05)",
    borderRadius: "7.5px",
    display: "flex",
    position: "relative",
  },

  replyginMsgBody: {
    padding: 10,
    height: "auto",
    display: "block",
    whiteSpace: "pre-wrap",
    overflow: "hidden",
  },

  replyginContactMsgSideColor: {
    flex: "none",
    width: "4px",
    backgroundColor: "#35cd96",
  },

  replyginSelfMsgSideColor: {
    flex: "none",
    width: "4px",
    backgroundColor: "#6bcbef",
  },

  messageContactName: {
    display: "flex",
    color: "#6bcbef",
    fontWeight: 500,
  },
  messageQuickAnswersWrapper: {
    margin: 0,
    position: "absolute",
    bottom: "50px",
    background: "#ffffff",
    padding: "2px",
    border: "1px solid #CCC",
    left: 0,
    width: "100%",
    "& li": {
      listStyle: "none",
      "& a": {
        display: "block",
        padding: "8px",
        textOverflow: "ellipsis",
        overflow: "hidden",
        maxHeight: "32px",
        "&:hover": {
          background: "#F1F1F1",
          cursor: "pointer",
        },
      },
    },
  },
}));

const MessageInput = ({ ticketStatus }) => {
  const classes = useStyles();
  const { ticketId } = useParams();

  const [medias, setMedias] = useState([]);
  const [inputMessage, setInputMessage] = useState("");
  const [showEmoji, setShowEmoji] = useState(false);
  const [loading, setLoading] = useState(false);
  const [recording, setRecording] = useState(false);
  const [quickAnswers, setQuickAnswer] = useState([]);
  const [typeBar, setTypeBar] = useState(false);
  const inputRef = useRef();
  const [anchorEl, setAnchorEl] = useState(null);
  const { setReplyingMessage, replyingMessage } =
    useContext(ReplyMessageContext);
  const { user } = useContext(AuthContext);
  const [channelType, setChannelType] = useState(null);

  const [signMessage, setSignMessage] = useLocalStorage("signOption", true);

  useEffect(() => {
    inputRef.current.focus();
  }, [replyingMessage]);

  useEffect(() => {
    const fetchChannelType = async () => {
      try {
        const { data } = await api.get(`/tickets/${ticketId}`);
        setChannelType(data.whatsapp?.type);
      } catch (err) {
        toastError(err);
      }
    };

    fetchChannelType();
  }, [ticketId]);

  useEffect(() => {
    inputRef.current.focus();
    return () => {
      setInputMessage("");
      setShowEmoji(false);
      setMedias([]);
      setReplyingMessage(null);
    };
  }, [ticketId, setReplyingMessage]);

  const handleChangeInput = (e) => {
    setInputMessage(e.target.value);
    handleLoadQuickAnswer(e.target.value);
  };

  const handleQuickAnswersClick = (value) => {
    setInputMessage(value);
    setTypeBar(false);
  };

  const handleAddEmoji = (e) => {
    let emoji = e.native;
    setInputMessage((prevState) => prevState + emoji);
  };

  const handleChangeMedias = (e) => {
    if (!e.target.files) {
      return;
    }

    const selectedMedias = Array.from(e.target.files);
    setMedias(selectedMedias);
  };

  const handleInputPaste = (e) => {
    if (e.clipboardData.files[0]) {
      setMedias([e.clipboardData.files[0]]);
    }
  };

  const handleUploadMedia = async (e) => {
    setLoading(true);
    e.preventDefault();

    const formData = new FormData();
    formData.append("fromMe", true);
    medias.forEach((media) => {
      formData.append("medias", media);
      formData.append("body", media.name);
    });

    try {
      if (channelType !== null) {
        await api.post(`/hub-message/${ticketId}`, formData);
      } else {
        await api.post(`/messages/${ticketId}`, formData);
      }
    } catch (err) {
      toastError(err);
    }

    setLoading(false);
    setMedias([]);
  };

  const handleSendMessage = async () => {
    if (inputMessage.trim() === "") return;
    setLoading(true);

    const message = {
      read: 1,
      fromMe: true,
      mediaUrl: "",
      body: signMessage
        ? `*${user?.name}:*\n${inputMessage.trim()}`
        : inputMessage.trim(),
      quotedMsg: replyingMessage,
    };
    try {
      if (channelType !== null) {
        await api.post(`/hub-message/${ticketId}`, message);
      } else {
        await api.post(`/messages/${ticketId}`, message);
      }
    } catch (err) {
      toastError(err);
    }

    setInputMessage("");
    setShowEmoji(false);
    setLoading(false);
    setReplyingMessage(null);
  };

  const handleStartRecording = async () => {
    setLoading(true);
    try {
      await navigator.mediaDevices.getUserMedia({ audio: true });
      await Mp3Recorder.start();
      setRecording(true);
      setLoading(false);
    } catch (err) {
      toastError(err);
      setLoading(false);
    }
  };

  const handleLoadQuickAnswer = async (value) => {
    if (value && value.indexOf("/") === 0) {
      try {
        const { data } = await api.get("/quickAnswers/", {
          params: { searchParam: inputMessage.substring(1) },
        });
        setQuickAnswer(data.quickAnswers);
        if (data.quickAnswers.length > 0) {
          setTypeBar(true);
        } else {
          setTypeBar(false);
        }
      } catch (err) {
        setTypeBar(false);
      }
    } else {
      setTypeBar(false);
    }
  };

  const handleUploadAudio = async () => {
    setLoading(true);
    try {
      const [, blob] = await Mp3Recorder.stop().getMp3();
      if (blob.size < 10000) {
        setLoading(false);
        setRecording(false);
        return;
      }

      const formData = new FormData();
      const filename = `${new Date().getTime()}.mp3`;
      formData.append("medias", blob, filename);
      formData.append("body", filename);
      formData.append("fromMe", true);

      if (channelType !== null) {
        await api.post(`/hub-message/${ticketId}`, formData);
      } else {
        await api.post(`/messages/${ticketId}`, formData);
      }

    } catch (err) {
      toastError(err);
    }

    setRecording(false);
    setLoading(false);
  };

  const handleCancelAudio = async () => {
    try {
      await Mp3Recorder.stop().getMp3();
      setRecording(false);
    } catch (err) {
      toastError(err);
    }
  };

  const handleOpenMenuClick = (event) => {
    setAnchorEl(event.currentTarget);
  };

  const handleMenuItemClick = (event) => {
    setAnchorEl(null);
  };

  const renderReplyingMessage = (message) => {
    return (
      <div className={classes.replyginMsgWrapper}>
        <div className={classes.replyginMsgContainer}>
          <span
            className={clsx(classes.replyginContactMsgSideColor, {
              [classes.replyginSelfMsgSideColor]: !message.fromMe,
            })}
          ></span>
          <div className={classes.replyginMsgBody}>
            {!message.fromMe && (
              <span className={classes.messageContactName}>
                {message.contact?.name}
              </span>
            )}
            {message.body}
          </div>
        </div>
        <IconButton
          aria-label="showRecorder"
          component="span"
          disabled={loading || ticketStatus !== "open"}
          onClick={() => setReplyingMessage(null)}
        >
          <ClearIcon className={classes.sendMessageIcons} />
        </IconButton>
      </div>
    );
  };

  if (medias.length > 0)
    return (
      <Paper elevation={0} square className={classes.viewMediaInputWrapper}>
        <IconButton
          aria-label="cancel-upload"
          component="span"
          onClick={(e) => setMedias([])}
        >
          <CancelIcon className={classes.sendMessageIcons} />
        </IconButton>

        {loading ? (
          <div>
            <CircularProgress className={classes.circleLoading} />
          </div>
        ) : (
          <span>
            {medias[0]?.name}
            {/* <img src={media.preview} alt=""></img> */}
          </span>
        )}
        <IconButton
          aria-label="send-upload"
          component="span"
          onClick={handleUploadMedia}
          disabled={loading}
        >
          <SendIcon className={classes.sendMessageIcons} />
        </IconButton>
      </Paper>
    );
  else {
    return (
      <Paper square elevation={0} className={classes.mainWrapper}>
        {replyingMessage && renderReplyingMessage(replyingMessage)}
        <div className={classes.newMessageBox}>
          <Hidden only={["sm", "xs"]}>
            <IconButton
              aria-label="emojiPicker"
              component="span"
              disabled={loading || recording || ticketStatus !== "open"}
              onClick={(e) => setShowEmoji((prevState) => !prevState)}
            >
              <MoodIcon className={classes.sendMessageIcons} />
            </IconButton>
            {showEmoji ? (
              <div className={classes.emojiBox}>
                <ClickAwayListener onClickAway={(e) => setShowEmoji(false)}>
                  <Picker
                    perLine={16}
                    showPreview={false}
                    showSkinTones={false}
                    onSelect={handleAddEmoji}
                  />
                </ClickAwayListener>
              </div>
            ) : null}

            <input
              multiple
              type="file"
              id="upload-button"
              disabled={loading || recording || ticketStatus !== "open"}
              className={classes.uploadInput}
              onChange={handleChangeMedias}
            />
            <label htmlFor="upload-button">
              <IconButton
                aria-label="upload"
                component="span"
                disabled={loading || recording || ticketStatus !== "open"}
              >
                <AttachFileIcon className={classes.sendMessageIcons} />
              </IconButton>
            </label>
            <FormControlLabel
              style={{ marginRight: 7, color: "gray" }}
              label={i18n.t("messagesInput.signMessage")}
              labelPlacement="start"
              control={
                <Switch
                  size="small"
                  checked={signMessage}
                  onChange={(e) => {
                    setSignMessage(e.target.checked);
                  }}
                  name="showAllTickets"
                  color="primary"
                />
              }
            />
          </Hidden>
          <Hidden only={["md", "lg", "xl"]}>
            <IconButton
              aria-controls="simple-menu"
              aria-haspopup="true"
              onClick={handleOpenMenuClick}
            >
              <MoreVert></MoreVert>
            </IconButton>
            <Menu
              id="simple-menu"
              keepMounted
              anchorEl={anchorEl}
              open={Boolean(anchorEl)}
              onClose={handleMenuItemClick}
            >
              <MenuItem onClick={handleMenuItemClick}>
                <IconButton
                  aria-label="emojiPicker"
                  component="span"
                  disabled={loading || recording || ticketStatus !== "open"}
                  onClick={(e) => setShowEmoji((prevState) => !prevState)}
                >
                  <MoodIcon className={classes.sendMessageIcons} />
                </IconButton>
              </MenuItem>
              <MenuItem onClick={handleMenuItemClick}>
                <input
                  multiple
                  type="file"
                  id="upload-button"
                  disabled={loading || recording || ticketStatus !== "open"}
                  className={classes.uploadInput}
                  onChange={handleChangeMedias}
                />
                <label htmlFor="upload-button">
                  <IconButton
                    aria-label="upload"
                    component="span"
                    disabled={loading || recording || ticketStatus !== "open"}
                  >
                    <AttachFileIcon className={classes.sendMessageIcons} />
                  </IconButton>
                </label>
              </MenuItem>
              <MenuItem onClick={handleMenuItemClick}>
                <FormControlLabel
                  style={{ marginRight: 7, color: "gray" }}
                  label={i18n.t("messagesInput.signMessage")}
                  labelPlacement="start"
                  control={
                    <Switch
                      size="small"
                      checked={signMessage}
                      onChange={(e) => {
                        setSignMessage(e.target.checked);
                      }}
                      name="showAllTickets"
                      color="primary"
                    />
                  }
                />
              </MenuItem>
            </Menu>
          </Hidden>
          <div className={classes.messageInputWrapper}>
            <InputBase
              inputRef={(input) => {
                input && input.focus();
                input && (inputRef.current = input);
              }}
              className={classes.messageInput}
              placeholder={
                ticketStatus === "open"
                  ? i18n.t("messagesInput.placeholderOpen")
                  : i18n.t("messagesInput.placeholderClosed")
              }
              multiline
              maxRows={5}
              value={inputMessage}
              onChange={handleChangeInput}
              disabled={recording || loading || ticketStatus !== "open"}
              onPaste={(e) => {
                ticketStatus === "open" && handleInputPaste(e);
              }}
              onKeyPress={(e) => {
                if (loading || e.shiftKey) return;
                else if (e.key === "Enter") {
                  handleSendMessage();
                }
              }}
            />
            {typeBar ? (
              <ul className={classes.messageQuickAnswersWrapper}>
                {quickAnswers.map((value, index) => {
                  return (
                    <li
                      className={classes.messageQuickAnswersWrapperItem}
                      key={index}
                    >
                      {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
                      <a onClick={() => handleQuickAnswersClick(value.message)}>
                        {`${value.shortcut} - ${value.message}`}
                      </a>
                    </li>
                  );
                })}
              </ul>
            ) : (
              <div></div>
            )}
          </div>
          {inputMessage ? (
            <IconButton
              aria-label="sendMessage"
              component="span"
              onClick={handleSendMessage}
              disabled={loading}
            >
              <SendIcon className={classes.sendMessageIcons} />
            </IconButton>
          ) : recording ? (
            <div className={classes.recorderWrapper}>
              <IconButton
                aria-label="cancelRecording"
                component="span"
                fontSize="large"
                disabled={loading}
                onClick={handleCancelAudio}
              >
                <HighlightOffIcon className={classes.cancelAudioIcon} />
              </IconButton>
              {loading ? (
                <div>
                  <CircularProgress className={classes.audioLoading} />
                </div>
              ) : (
                <RecordingTimer />
              )}

              <IconButton
                aria-label="sendRecordedAudio"
                component="span"
                onClick={handleUploadAudio}
                disabled={loading}
              >
                <CheckCircleOutlineIcon className={classes.sendAudioIcon} />
              </IconButton>
            </div>
          ) : (
            <IconButton
              aria-label="showRecorder"
              component="span"
              disabled={loading || ticketStatus !== "open"}
              onClick={handleStartRecording}
            >
              <MicIcon className={classes.sendMessageIcons} />
            </IconButton>
          )}
        </div>
      </Paper>
    );
  }
};

export default MessageInput;

frontend\src\components\TicketListItem\index.js​


Código:
import React, { useState, useEffect, useRef, useContext } from "react";

import { useHistory, useParams } from "react-router-dom";
import { parseISO, format, isSameDay } from "date-fns";
import clsx from "clsx";

import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";
import ListItem from "@material-ui/core/ListItem";
import ListItemText from "@material-ui/core/ListItemText";
import ListItemAvatar from "@material-ui/core/ListItemAvatar";
import Typography from "@material-ui/core/Typography";
import Avatar from "@material-ui/core/Avatar";
import Divider from "@material-ui/core/Divider";
import Badge from "@material-ui/core/Badge";

import { i18n } from "../../translate/i18n";

import api from "../../services/api";
import ButtonWithSpinner from "../ButtonWithSpinner";
import MarkdownWrapper from "../MarkdownWrapper";
import { Tooltip } from "@material-ui/core";
import { AuthContext } from "../../context/Auth/AuthContext";
import toastError from "../../errors/toastError";

import FacebookIcon from "@material-ui/icons/Facebook";
import InstagramIcon from "@material-ui/icons/Instagram";
import WhatsAppIcon from "@material-ui/icons/WhatsApp";

const useStyles = makeStyles(theme => ({
    ticket: {
        position: "relative",
    },

    pendingTicket: {
        cursor: "unset",
    },

    noTicketsDiv: {
        display: "flex",
        height: "100px",
        margin: 40,
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
    },

    noTicketsText: {
        textAlign: "center",
        color: "rgb(104, 121, 146)",
        fontSize: "14px",
        lineHeight: "1.4",
    },

    noTicketsTitle: {
        textAlign: "center",
        fontSize: "16px",
        fontWeight: "600",
        margin: "0px",
    },

    contactNameWrapper: {
        display: "flex",
        justifyContent: "space-between",
    },

    lastMessageTime: {
        justifySelf: "flex-end",
    },

    closedBadge: {
        alignSelf: "center",
        justifySelf: "flex-end",
        marginRight: 32,
        marginLeft: "auto",
    },

    contactLastMessage: {
        paddingRight: 20,
    },

    newMessagesCount: {
        alignSelf: "center",
        marginRight: 8,
        marginLeft: "auto",
    },

    badgeStyle: {
        color: "white",
        backgroundColor: green[500],
    },

    acceptButton: {
        position: "absolute",
        left: "50%",
    },

    ticketQueueColor: {
        flex: "none",
        width: "8px",
        height: "100%",
        position: "absolute",
        top: "0%",
        left: "0%",
    },

    userTag: {
        position: "absolute",
        marginRight: 5,
        right: 5,
        bottom: 5,
        background: "#2576D2",
        color: "#ffffff",
        border: "1px solid #CCC",
        padding: 1,
        paddingLeft: 5,
        paddingRight: 5,
        borderRadius: 10,
        fontSize: "0.9em"
    },
}));

const TicketListItem = ({ ticket }) => {
    const classes = useStyles();
    const history = useHistory();
    const [loading, setLoading] = useState(false);
    const { ticketId } = useParams();
    const isMounted = useRef(true);
    const { user } = useContext(AuthContext);

    useEffect(() => {
        return () => {
            isMounted.current = false;
        };
    }, []);

    const handleAcepptTicket = async id => {
        setLoading(true);
        try {
            await api.put(`/tickets/${id}`, {
                status: "open",
                userId: user?.id,
            });
        } catch (err) {
            setLoading(false);
            toastError(err);
        }
        if (isMounted.current) {
            setLoading(false);
        }
        history.push(`/tickets/${id}`);
    };

    const handleSelectTicket = id => {
        history.push(`/tickets/${id}`);
    };

    return (
        <React.Fragment key={ticket.id}>
            <ListItem
                dense
                button
                onClick={e => {
                    if (ticket.status === "pending") return;
                    handleSelectTicket(ticket.id);
                }}
                selected={ticketId && +ticketId === ticket.id}
                className={clsx(classes.ticket, {
                    [classes.pendingTicket]: ticket.status === "pending",
                })}
            >
                <Tooltip
                    arrow
                    placement="right"
                    title={ticket.queue?.name || "Sem fila"}
                >
                    <span
                        style={{ backgroundColor: ticket.queue?.color || "#7C7C7C" }}
                        className={classes.ticketQueueColor}
                    ></span>
                </Tooltip>
                <ListItemAvatar>
                    <Avatar src={ticket?.contact?.profilePicUrl} />
                </ListItemAvatar>
                <ListItemText
                    disableTypography
                    primary={
                        <span className={classes.contactNameWrapper}>
                            <Typography
                                noWrap
                                component="span"
                                variant="body2"
                                color="textPrimary"
                            >
                                {ticket.contact.name}
                            </Typography>
                            {ticket.status === "closed" && (
                                <Badge
                                    className={classes.closedBadge}
                                    badgeContent={"closed"}
                                    color="primary"
                                />
                            )}
                            {ticket.lastMessage && (
                                <Typography
                                    className={classes.lastMessageTime}
                                    component="span"
                                    variant="body2"
                                    color="textSecondary"
                                >
                                    {isSameDay(parseISO(ticket.updatedAt), new Date()) ? (
                                        <>{format(parseISO(ticket.updatedAt), "HH:mm")}</>
                                    ) : (
                                        <>{format(parseISO(ticket.updatedAt), "dd/MM/yyyy")}</>
                                    )}
                                </Typography>
                            )}
                            {ticket.whatsappId && (
                                <div className={classes.userTag} title={i18n.t("ticketsList.connectionTitle")}>{ticket.whatsapp?.name}</div>
                            )}
                            {ticket.contact.messengerId && (
                                <FacebookIcon />
                            )}
                            {ticket.contact.instagramId && (
                                <InstagramIcon />
                            )}
                            {ticket.contact.number && (
                                <WhatsAppIcon />
                            )}
                        </span>
                    }
                    secondary={
                        <span className={classes.contactNameWrapper}>
                            <Typography
                                className={classes.contactLastMessage}
                                noWrap
                                component="span"
                                variant="body2"
                                color="textSecondary"
                            >
                                {ticket.lastMessage ? (
                                    <MarkdownWrapper>{ticket.lastMessage}</MarkdownWrapper>
                                ) : (
                                    <br />
                                )}
                            </Typography>

                            <Badge
                                className={classes.newMessagesCount}
                                badgeContent={ticket.unreadMessages}
                                classes={{
                                    badge: classes.badgeStyle,
                                }}
                            />
                        </span>
                    }
                />
                {ticket.status === "pending" && (
                    <ButtonWithSpinner
                        color="primary"
                        variant="contained"
                        className={classes.acceptButton}
                        size="small"
                        loading={loading}
                        onClick={e => handleAcepptTicket(ticket.id)}
                    >
                        {i18n.t("ticketsList.buttons.accept")}
                    </ButtonWithSpinner>
                )}
            </ListItem>
            <Divider variant="inset" component="li" />
        </React.Fragment>
    );
};

export default TicketListItem;

frontend\src\components\WhatsAppModal\index.js​


Código:
import React, { useState, useEffect } from "react";
import * as Yup from "yup";
import { Formik, Form, Field } from "formik";
import { toast } from "react-toastify";

import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";

import {
    Dialog,
    DialogContent,
    DialogTitle,
    Button,
    DialogActions,
    CircularProgress,
    TextField,
    Switch,
    FormControlLabel,
    Select,
    MenuItem,
} from "@material-ui/core";

import api from "../../services/api";
import { i18n } from "../../translate/i18n";
import toastError from "../../errors/toastError";
import QueueSelect from "../QueueSelect";

const useStyles = makeStyles(theme => ({
    root: {
        display: "flex",
        flexWrap: "wrap",
    },

    multFieldLine: {
        display: "flex",
        "& > *:not(:last-child)": {
            marginRight: theme.spacing(1),
        },
    },

    btnWrapper: {
        position: "relative",
    },

    buttonProgress: {
        color: green[500],
        position: "absolute",
        top: "50%",
        left: "50%",
        marginTop: -12,
        marginLeft: -12,
    },
}));

const SessionSchema = Yup.object().shape({
    name: Yup.string()
        .min(2, "Too Short!")
        .max(50, "Too Long!")
        .required("Required"),
});

const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
    const classes = useStyles();
    const initialState = {
        name: "",
        greetingMessage: "",
        farewellMessage: "",
        isDefault: false,
    };
    const [whatsApp, setWhatsApp] = useState(initialState);
    const [selectedQueueIds, setSelectedQueueIds] = useState([]);
    const [isHubSelected, setIsHubSelected] = useState(false);
    const [availableChannels, setAvailableChannels] = useState([]);
    const [selectedChannel, setSelectedChannel] = useState("");

    // Função para buscar os canais disponíveis no hub
    const fetchChannels = async () => {
        try {
            const { data } = await api.get("/hub-channel/");
            console.log("Canais disponíveis:", data); // Adicione isso para verificar os dados recebidos
            setAvailableChannels(data);
        } catch (err) {
            toastError(err);
        }
    };

    useEffect(() => {
        console.log("selectedChannel has changed:", selectedChannel);
    }, [selectedChannel]);

    useEffect(() => {
        const fetchSession = async () => {
            if (!whatsAppId) return;

            try {
                const { data } = await api.get(`whatsapp/${whatsAppId}`);
                setWhatsApp(data);

                const whatsQueueIds = data.queues?.map(queue => queue.id);
                setSelectedQueueIds(whatsQueueIds);
            } catch (err) {
                toastError(err);
            }
        };
        fetchSession();
    }, [whatsAppId]);

    const handleSaveWhatsApp = async values => {
        const whatsappData = { ...values, queueIds: selectedQueueIds };

        try {
            if (isHubSelected && selectedChannel) {
                // Encontrar o objeto do canal completo baseado no ID do canal selecionado
                const selectedChannelObj = availableChannels.find(
                    channel => channel.id === selectedChannel
                );

                if (selectedChannelObj) {
                    // Enviar o objeto completo do canal
                    const channels = [selectedChannelObj];
                    await api.post("/hub-channel/", {
                        ...whatsappData,
                        channels
                    });
                    setTimeout(() => {
                        window.location.reload();
                    }, 100);
                }
            } else {
                if (whatsAppId) {
                    await api.put(`/whatsapp/${whatsAppId}`, ...whatsappData);
                } else {
                    await api.post("/whatsapp", whatsappData);
                }
            }
            toast.success(i18n.t("whatsappModal.success"));
            handleClose();
        } catch (err) {
            toastError(err);
        }
    };


    const handleClose = () => {
        onClose();
        setWhatsApp(initialState);
        setIsHubSelected(false);
        setSelectedChannel("");
    };

    return (
        <div className={classes.root}>
            <Dialog
                open={open}
                onClose={handleClose}
                maxWidth="sm"
                fullWidth
                scroll="paper"
            >
                <DialogTitle>
                    {whatsAppId
                        ? i18n.t("whatsappModal.title.edit")
                        : i18n.t("whatsappModal.title.add")}
                </DialogTitle>
                <Formik
                    initialValues={whatsApp}
                    enableReinitialize={true}
                    validationSchema={SessionSchema}
                    onSubmit={(values, actions) => {
                        setTimeout(() => {
                            handleSaveWhatsApp(values);
                            actions.setSubmitting(false);
                        }, 400);
                    }}
                >
                    {({ values, touched, errors, isSubmitting }) => (
                        <Form>
                            <DialogContent dividers>
                                <div className={classes.multFieldLine}>
                                    <Field
                                        as={TextField}
                                        label={i18n.t("whatsappModal.form.name")}
                                        autoFocus
                                        name="name"
                                        error={touched.name && Boolean(errors.name)}
                                        helperText={touched.name && errors.name}
                                        variant="outlined"
                                        margin="dense"
                                        className={classes.textField}
                                    />
                                    <FormControlLabel
                                        control={
                                            <Switch
                                                checked={isHubSelected}
                                                onChange={() => {
                                                    setIsHubSelected(prev => !prev);
                                                    if (!isHubSelected) {
                                                        fetchChannels();
                                                    }
                                                }}
                                                color="primary"
                                            />
                                        }
                                        label="Hub Notifcame"
                                    />
                                    {!isHubSelected && (
                                        <>

                                            <FormControlLabel
                                                control={
                                                    <Field
                                                        as={Switch}
                                                        color="primary"
                                                        name="isDefault"
                                                        checked={values.isDefault}
                                                    />
                                                }
                                                label={i18n.t("whatsappModal.form.default")}
                                            />
                                        </>
                                    )}

                                </div>

                                {/* Se um hub for selecionado, mostrar lista de canais */}
                                {isHubSelected && (
                                    <div>
                                        <Select
                                            label="Select Channel"
                                            fullWidth
                                            value={selectedChannel || ""} // Use '' como fallback para valores undefined
                                            onChange={e => {
                                                const value = e.target.value;
                                                setSelectedChannel(value); // Mantenha o ID selecionado para buscar o objeto completo
                                            }}
                                            displayEmpty
                                        >
                                            <MenuItem value="" disabled>
                                                Selecione um canal
                                            </MenuItem>
                                            {availableChannels.map(channel => (
                                                <MenuItem key={channel.id} value={channel.id}>
                                                    {channel.name}
                                                </MenuItem>
                                            ))}
                                        </Select>
                                    </div>
                                )}

                                {!isHubSelected && (
                                    <>
                                        <div>
                                            <Field
                                                as={TextField}
                                                label={i18n.t("queueModal.form.greetingMessage")}
                                                type="greetingMessage"
                                                multiline
                                                rows={5}
                                                fullWidth
                                                name="greetingMessage"
                                                error={
                                                    touched.greetingMessage && Boolean(errors.greetingMessage)
                                                }
                                                helperText={
                                                    touched.greetingMessage && errors.greetingMessage
                                                }
                                                variant="outlined"
                                                margin="dense"
                                            />
                                        </div>
                                        <div>
                                            <Field
                                                as={TextField}
                                                label={i18n.t("whatsappModal.form.farewellMessage")}
                                                type="farewellMessage"
                                                multiline
                                                rows={5}
                                                fullWidth
                                                name="farewellMessage"
                                                error={
                                                    touched.farewellMessage && Boolean(errors.farewellMessage)
                                                }
                                                helperText={
                                                    touched.farewellMessage && errors.farewellMessage
                                                }
                                                variant="outlined"
                                                margin="dense"
                                            />
                                        </div>
                                        <QueueSelect
                                            selectedQueueIds={selectedQueueIds}
                                            onChange={selectedIds => setSelectedQueueIds(selectedIds)}
                                        />
                                    </>
                                )}

                            </DialogContent>
                            <DialogActions>
                                <Button
                                    onClick={handleClose}
                                    color="secondary"
                                    disabled={isSubmitting}
                                    variant="outlined"
                                >
                                    {i18n.t("whatsappModal.buttons.cancel")}
                                </Button>
                                <Button
                                    type="submit"
                                    color="primary"
                                    disabled={isSubmitting}
                                    variant="contained"
                                    className={classes.btnWrapper}
                                >
                                    {whatsAppId
                                        ? i18n.t("whatsappModal.buttons.okEdit")
                                        : i18n.t("whatsappModal.buttons.okAdd")}
                                    {isSubmitting && (
                                        <CircularProgress
                                            size={24}
                                            className={classes.buttonProgress}
                                        />
                                    )}
                                </Button>
                            </DialogActions>
                        </Form>
                    )}
                </Formik>
            </Dialog>
        </div>
    );
};

export default React.memo(WhatsAppModal);

PAGES​

frontend\src\pages\Connections\index.js​


Código:
import React, { useState, useCallback, useContext } from "react";
import { toast } from "react-toastify";
import { format, parseISO } from "date-fns";

import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";
import {
    Button,
    TableBody,
    TableRow,
    TableCell,
    IconButton,
    Table,
    TableHead,
    Paper,
    Tooltip,
    Typography,
    CircularProgress,
} from "@material-ui/core";
import {
    Edit,
    CheckCircle,
    SignalCellularConnectedNoInternet2Bar,
    SignalCellularConnectedNoInternet0Bar,
    SignalCellular4Bar,
    CropFree,
    DeleteOutline,
} from "@material-ui/icons";

import MainContainer from "../../components/MainContainer";
import MainHeader from "../../components/MainHeader";
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
import Title from "../../components/Title";
import TableRowSkeleton from "../../components/TableRowSkeleton";

import api from "../../services/api";
import WhatsAppModal from "../../components/WhatsAppModal";
import ConfirmationModal from "../../components/ConfirmationModal";
import QrcodeModal from "../../components/QrcodeModal";
import { i18n } from "../../translate/i18n";
import { WhatsAppsContext } from "../../context/WhatsApp/WhatsAppsContext";
import toastError from "../../errors/toastError";

import FacebookIcon from "@material-ui/icons/Facebook";
import InstagramIcon from "@material-ui/icons/Instagram";
import WhatsAppIcon from "@material-ui/icons/WhatsApp";

const useStyles = makeStyles(theme => ({
    mainPaper: {
        flex: 1,
        padding: theme.spacing(1),
        overflowY: "scroll",
        ...theme.scrollbarStyles,
    },
    customTableCell: {
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
    },
    tooltip: {
        backgroundColor: "#f5f5f9",
        color: "rgba(0, 0, 0, 0.87)",
        fontSize: theme.typography.pxToRem(14),
        border: "1px solid #dadde9",
        maxWidth: 450,
    },
    tooltipPopper: {
        textAlign: "center",
    },
    buttonProgress: {
        color: green[500],
    },
}));

const CustomToolTip = ({ title, content, children }) => {
    const classes = useStyles();

    return (
        <Tooltip
            arrow
            classes={{
                tooltip: classes.tooltip,
                popper: classes.tooltipPopper,
            }}
            title={
                <React.Fragment>
                    <Typography gutterBottom color="inherit">
                        {title}
                    </Typography>
                    {content && <Typography>{content}</Typography>}
                </React.Fragment>
            }
        >
            {children}
        </Tooltip>
    );
};

const Connections = () => {
    const classes = useStyles();

    const { whatsApps, loading } = useContext(WhatsAppsContext);
    const [whatsAppModalOpen, setWhatsAppModalOpen] = useState(false);
    const [qrModalOpen, setQrModalOpen] = useState(false);
    const [selectedWhatsApp, setSelectedWhatsApp] = useState(null);
    const [confirmModalOpen, setConfirmModalOpen] = useState(false);
    const confirmationModalInitialState = {
        action: "",
        title: "",
        message: "",
        whatsAppId: "",
        open: false,
    };
    const [confirmModalInfo, setConfirmModalInfo] = useState(
        confirmationModalInitialState
    );

    const getChannelIcon = (channel) => {
        switch (channel.toLowerCase()) {
          case "facebook":
            return <FacebookIcon />;
          case "instagram":
            return <InstagramIcon />;
          case "whatsapp":
            return <WhatsAppIcon />;
          default:
            return null;
        }
      };

    const handleStartWhatsAppSession = async whatsAppId => {
        try {
            await api.post(`/whatsappsession/${whatsAppId}`);
        } catch (err) {
            toastError(err);
        }
    };

    const handleRequestNewQrCode = async whatsAppId => {
        try {
            await api.put(`/whatsappsession/${whatsAppId}`);
        } catch (err) {
            toastError(err);
        }
    };

    const handleOpenWhatsAppModal = () => {
        setSelectedWhatsApp(null);
        setWhatsAppModalOpen(true);
    };

    const handleCloseWhatsAppModal = useCallback(() => {
        setWhatsAppModalOpen(false);
        setSelectedWhatsApp(null);
    }, [setSelectedWhatsApp, setWhatsAppModalOpen]);

    const handleOpenQrModal = whatsApp => {
        setSelectedWhatsApp(whatsApp);
        setQrModalOpen(true);
    };

    const handleCloseQrModal = useCallback(() => {
        setSelectedWhatsApp(null);
        setQrModalOpen(false);
    }, [setQrModalOpen, setSelectedWhatsApp]);

    const handleEditWhatsApp = whatsApp => {
        setSelectedWhatsApp(whatsApp);
        setWhatsAppModalOpen(true);
    };

    const handleOpenConfirmationModal = (action, whatsAppId) => {
        if (action === "disconnect") {
            setConfirmModalInfo({
                action: action,
                title: i18n.t("connections.confirmationModal.disconnectTitle"),
                message: i18n.t("connections.confirmationModal.disconnectMessage"),
                whatsAppId: whatsAppId,
            });
        }

        if (action === "delete") {
            setConfirmModalInfo({
                action: action,
                title: i18n.t("connections.confirmationModal.deleteTitle"),
                message: i18n.t("connections.confirmationModal.deleteMessage"),
                whatsAppId: whatsAppId,
            });
        }
        setConfirmModalOpen(true);
    };

    const handleSubmitConfirmationModal = async () => {
        if (confirmModalInfo.action === "disconnect") {
            try {
                await api.delete(`/whatsappsession/${confirmModalInfo.whatsAppId}`);
            } catch (err) {
                toastError(err);
            }
        }

        if (confirmModalInfo.action === "delete") {
            try {
                await api.delete(`/whatsapp/${confirmModalInfo.whatsAppId}`);
                toast.success(i18n.t("connections.toasts.deleted"));
            } catch (err) {
                toastError(err);
            }
        }

        setConfirmModalInfo(confirmationModalInitialState);
    };

    const renderActionButtons = whatsApp => {
        return (
            <>
                {whatsApp.status === "qrcode" && (
                    <Button
                        size="small"
                        variant="contained"
                        color="primary"
                        onClick={() => handleOpenQrModal(whatsApp)}
                    >
                        {i18n.t("connections.buttons.qrcode")}
                    </Button>
                )}
                {whatsApp.status === "DISCONNECTED" && (
                    <>
                        <Button
                            size="small"
                            variant="outlined"
                            color="primary"
                            onClick={() => handleStartWhatsAppSession(whatsApp.id)}
                        >
                            {i18n.t("connections.buttons.tryAgain")}
                        </Button>{" "}
                        <Button
                            size="small"
                            variant="outlined"
                            color="secondary"
                            onClick={() => handleRequestNewQrCode(whatsApp.id)}
                        >
                            {i18n.t("connections.buttons.newQr")}
                        </Button>
                    </>
                )}
                {(whatsApp.status === "CONNECTED" ||
                    whatsApp.status === "PAIRING" ||
                    whatsApp.status === "TIMEOUT") &&
                    whatsApp.type.toLowerCase() === "whatsapp" && (
                    <Button
                        size="small"
                        variant="outlined"
                        color="secondary"
                        onClick={() => {
                            handleOpenConfirmationModal("disconnect", whatsApp.id);
                        }}
                    >
                        {i18n.t("connections.buttons.disconnect")}
                    </Button>
                )}
                {whatsApp.status === "OPENING" && (
                    <Button size="small" variant="outlined" disabled color="default">
                        {i18n.t("connections.buttons.connecting")}
                    </Button>
                )}
            </>
        );
    };

    const renderStatusToolTips = whatsApp => {
        return (
            <div className={classes.customTableCell}>
                {whatsApp.status === "DISCONNECTED" && (
                    <CustomToolTip
                        title={i18n.t("connections.toolTips.disconnected.title")}
                        content={i18n.t("connections.toolTips.disconnected.content")}
                    >
                        <SignalCellularConnectedNoInternet0Bar color="secondary" />
                    </CustomToolTip>
                )}
                {whatsApp.status === "OPENING" && (
                    <CircularProgress size={24} className={classes.buttonProgress} />
                )}
                {whatsApp.status === "qrcode" && (
                    <CustomToolTip
                        title={i18n.t("connections.toolTips.qrcode.title")}
                        content={i18n.t("connections.toolTips.qrcode.content")}
                    >
                        <CropFree />
                    </CustomToolTip>
                )}
                {whatsApp.status === "CONNECTED" && (
                    <CustomToolTip title={i18n.t("connections.toolTips.connected.title")}>
                        <SignalCellular4Bar style={{ color: green[500] }} />
                    </CustomToolTip>
                )}
                {(whatsApp.status === "TIMEOUT" || whatsApp.status === "PAIRING") && (
                    <CustomToolTip
                        title={i18n.t("connections.toolTips.timeout.title")}
                        content={i18n.t("connections.toolTips.timeout.content")}
                    >
                        <SignalCellularConnectedNoInternet2Bar color="secondary" />
                    </CustomToolTip>
                )}
            </div>
        );
    };

    return (
        <MainContainer>
            <ConfirmationModal
                title={confirmModalInfo.title}
                open={confirmModalOpen}
                onClose={setConfirmModalOpen}
                onConfirm={handleSubmitConfirmationModal}
            >
                {confirmModalInfo.message}
            </ConfirmationModal>
            <QrcodeModal
                open={qrModalOpen}
                onClose={handleCloseQrModal}
                whatsAppId={!whatsAppModalOpen && selectedWhatsApp?.id}
            />
            <WhatsAppModal
                open={whatsAppModalOpen}
                onClose={handleCloseWhatsAppModal}
                whatsAppId={!qrModalOpen && selectedWhatsApp?.id}
            />
            <MainHeader>
                <Title>{i18n.t("connections.title")}</Title>
                <MainHeaderButtonsWrapper>
                    <Button
                        variant="contained"
                        color="primary"
                        onClick={handleOpenWhatsAppModal}
                    >
                        {i18n.t("connections.buttons.add")}
                    </Button>
                </MainHeaderButtonsWrapper>
            </MainHeader>
            <Paper className={classes.mainPaper} variant="outlined">
                <Table size="small">
                    <TableHead>
                        <TableRow>
                            <TableCell align="center">
                                {i18n.t("connections.table.name")}
                            </TableCell>
                            <TableCell align="center">
                                Canal
                            </TableCell>
                            <TableCell align="center">
                                {i18n.t("connections.table.status")}
                            </TableCell>
                            <TableCell align="center">
                                {i18n.t("connections.table.session")}
                            </TableCell>
                            <TableCell align="center">
                                {i18n.t("connections.table.lastUpdate")}
                            </TableCell>
                            <TableCell align="center">
                                {i18n.t("connections.table.default")}
                            </TableCell>
                            <TableCell align="center">
                                {i18n.t("connections.table.actions")}
                            </TableCell>
                        </TableRow>
                    </TableHead>
                    <TableBody>
                        {loading ? (
                            <TableRowSkeleton />
                        ) : (
                            <>
                                {whatsApps?.length > 0 &&
                                    whatsApps.map(whatsApp => (
                                        <TableRow key={whatsApp.id}>
                                            <TableCell align="center">{whatsApp.name}</TableCell>
                                            <TableCell align="center"> {getChannelIcon(whatsApp.type)}</TableCell>
                                            <TableCell align="center">
                                                {renderStatusToolTips(whatsApp)}
                                            </TableCell>
                                            <TableCell align="center">
                                                {renderActionButtons(whatsApp)}
                                            </TableCell>
                                            <TableCell align="center">
                                                {format(parseISO(whatsApp.updatedAt), "dd/MM/yy HH:mm")}
                                            </TableCell>
                                            <TableCell align="center">
                                                {whatsApp.isDefault && (
                                                    <div className={classes.customTableCell}>
                                                        <CheckCircle style={{ color: green[500] }} />
                                                    </div>
                                                )}
                                            </TableCell>
                                            <TableCell align="center">
                                                {whatsApp.type.toLowerCase() === "whatsapp" && (
                                                    <IconButton
                                                        size="small"
                                                        onClick={() => handleEditWhatsApp(whatsApp)}
                                                    >
                                                        <Edit />
                                                    </IconButton>
                                                )}

                                                <IconButton
                                                    size="small"
                                                    onClick={e => {
                                                        handleOpenConfirmationModal("delete", whatsApp.id);
                                                    }}
                                                >
                                                    <DeleteOutline />
                                                </IconButton>
                                            </TableCell>
                                        </TableRow>
                                    ))}
                            </>
                        )}
                    </TableBody>
                </Table>
            </Paper>
        </MainContainer>
    );
};

export default Connections;

frontend\src\pages\Contacts\index.js​


Código:
import React, { useState, useEffect, useReducer, useContext } from "react";
import openSocket from "../../services/socket-io";
import { toast } from "react-toastify";
import { useHistory } from "react-router-dom";

import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";
import Button from "@material-ui/core/Button";
import Avatar from "@material-ui/core/Avatar";
import WhatsAppIcon from "@material-ui/icons/WhatsApp";
import FacebookIcon from "@material-ui/icons/Facebook";
import InstagramIcon from "@material-ui/icons/Instagram";
import SearchIcon from "@material-ui/icons/Search";
import TextField from "@material-ui/core/TextField";
import InputAdornment from "@material-ui/core/InputAdornment";

import IconButton from "@material-ui/core/IconButton";
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
import EditIcon from "@material-ui/icons/Edit";

import api from "../../services/api";
import TableRowSkeleton from "../../components/TableRowSkeleton";
import ContactModal from "../../components/ContactModal";
import ConfirmationModal from "../../components/ConfirmationModal/";

import { i18n } from "../../translate/i18n";
import MainHeader from "../../components/MainHeader";
import Title from "../../components/Title";
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
import MainContainer from "../../components/MainContainer";
import toastError from "../../errors/toastError";
import { AuthContext } from "../../context/Auth/AuthContext";
import { Can } from "../../components/Can";

const reducer = (state, action) => {
  if (action.type === "LOAD_CONTACTS") {
    const contacts = action.payload;
    const newContacts = [];

    contacts.forEach((contact) => {
      const contactIndex = state.findIndex((c) => c.id === contact.id);
      if (contactIndex !== -1) {
        state[contactIndex] = contact;
      } else {
        newContacts.push(contact);
      }
    });

    return [...state, ...newContacts];
  }

  if (action.type === "UPDATE_CONTACTS") {
    const contact = action.payload;
    const contactIndex = state.findIndex((c) => c.id === contact.id);

    if (contactIndex !== -1) {
      state[contactIndex] = contact;
      return [...state];
    } else {
      return [contact, ...state];
    }
  }

  if (action.type === "DELETE_CONTACT") {
    const contactId = action.payload;

    const contactIndex = state.findIndex((c) => c.id === contactId);
    if (contactIndex !== -1) {
      state.splice(contactIndex, 1);
    }
    return [...state];
  }

  if (action.type === "RESET") {
    return [];
  }
};

const useStyles = makeStyles((theme) => ({
  mainPaper: {
    flex: 1,
    padding: theme.spacing(1),
    overflowY: "scroll",
    ...theme.scrollbarStyles,
  },
}));

const Contacts = () => {
  const classes = useStyles();
  const history = useHistory();

  const { user } = useContext(AuthContext);

  const [loading, setLoading] = useState(false);
  const [pageNumber, setPageNumber] = useState(1);
  const [searchParam, setSearchParam] = useState("");
  const [contacts, dispatch] = useReducer(reducer, []);
  const [selectedContactId, setSelectedContactId] = useState(null);
  const [contactModalOpen, setContactModalOpen] = useState(false);
  const [deletingContact, setDeletingContact] = useState(null);
  const [confirmOpen, setConfirmOpen] = useState(false);
  const [hasMore, setHasMore] = useState(false);

  useEffect(() => {
    dispatch({ type: "RESET" });
    setPageNumber(1);
  }, [searchParam]);

  useEffect(() => {
    setLoading(true);
    const delayDebounceFn = setTimeout(() => {
      const fetchContacts = async () => {
        try {
          const { data } = await api.get("/contacts/", {
            params: { searchParam, pageNumber },
          });
          dispatch({ type: "LOAD_CONTACTS", payload: data.contacts });
          setHasMore(data.hasMore);
          setLoading(false);
        } catch (err) {
          toastError(err);
        }
      };
      fetchContacts();
    }, 500);
    return () => clearTimeout(delayDebounceFn);
  }, [searchParam, pageNumber]);

  useEffect(() => {
    const socket = openSocket();

    socket.on("contact", (data) => {
      if (data.action === "update" || data.action === "create") {
        dispatch({ type: "UPDATE_CONTACTS", payload: data.contact });
      }

      if (data.action === "delete") {
        dispatch({ type: "DELETE_CONTACT", payload: +data.contactId });
      }
    });

    return () => {
      socket.disconnect();
    };
  }, []);

  const handleSearch = (event) => {
    setSearchParam(event.target.value.toLowerCase());
  };

  const handleOpenContactModal = () => {
    setSelectedContactId(null);
    setContactModalOpen(true);
  };

  const handleCloseContactModal = () => {
    setSelectedContactId(null);
    setContactModalOpen(false);
  };

  const handleSaveTicket = async (contactId) => {
    if (!contactId) return;
    const { data } = await api.get(`/contacts/${contactId}`);
    setLoading(true);
    if(data.number){
      try {
        const { data: ticket } = await api.post("/tickets", {
          contactId: contactId,
          userId: user?.id,
          status: "open",
        });
        history.push(`/tickets/${ticket.id}`);
      } catch (err) {
        toastError(err);
      }
    } else if(!data.number && data.instagramId && !data.messengerId){
      try {
        const { data: ticket } = await api.post("/hub-ticket", {
          contactId: contactId,
          userId: user?.id,
          status: "open",
          channel: "instagram"
        });
        history.push(`/tickets/${ticket.id}`);
      } catch (err) {
        toastError(err);
      }
    } else if(!data.number && data.messengerId && !data.instagramId){
      try {
        const { data: ticket } = await api.post("/hub-ticket", {
          contactId: contactId,
          userId: user?.id,
          status: "open",
          channel: "facebook"
        });
        history.push(`/tickets/${ticket.id}`);
      } catch (err) {
        toastError(err);
      }
    }
    setLoading(false);
  };

  const hadleEditContact = (contactId) => {
    setSelectedContactId(contactId);
    setContactModalOpen(true);
  };

  const handleDeleteContact = async (contactId) => {
    try {
      await api.delete(`/contacts/${contactId}`);
      toast.success(i18n.t("contacts.toasts.deleted"));
    } catch (err) {
      toastError(err);
    }
    setDeletingContact(null);
    setSearchParam("");
    setPageNumber(1);
  };

  const handleimportContact = async () => {
    try {
      await api.post("/contacts/import");
      history.go(0);
    } catch (err) {
      toastError(err);
    }
  };

  const loadMore = () => {
    setPageNumber((prevState) => prevState + 1);
  };

  const handleScroll = (e) => {
    if (!hasMore || loading) return;
    const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
    if (scrollHeight - (scrollTop + 100) < clientHeight) {
      loadMore();
    }
  };

  return (
    <MainContainer className={classes.mainContainer}>
      <ContactModal
        open={contactModalOpen}
        onClose={handleCloseContactModal}
        aria-labelledby="form-dialog-title"
        contactId={selectedContactId}
      ></ContactModal>
      <ConfirmationModal
        title={
          deletingContact
            ? `${i18n.t("contacts.confirmationModal.deleteTitle")} ${
                deletingContact.name
              }?`
            : `${i18n.t("contacts.confirmationModal.importTitlte")}`
        }
        open={confirmOpen}
        onClose={setConfirmOpen}
        onConfirm={(e) =>
          deletingContact
            ? handleDeleteContact(deletingContact.id)
            : handleimportContact()
        }
      >
        {deletingContact
          ? `${i18n.t("contacts.confirmationModal.deleteMessage")}`
          : `${i18n.t("contacts.confirmationModal.importMessage")}`}
      </ConfirmationModal>
      <MainHeader>
        <Title>{i18n.t("contacts.title")}</Title>
        <MainHeaderButtonsWrapper>
          <TextField
            placeholder={i18n.t("contacts.searchPlaceholder")}
            type="search"
            value={searchParam}
            onChange={handleSearch}
            InputProps={{
              startAdornment: (
                <InputAdornment position="start">
                  <SearchIcon style={{ color: "gray" }} />
                </InputAdornment>
              ),
            }}
          />
          <Button
            variant="contained"
            color="primary"
            onClick={(e) => setConfirmOpen(true)}
          >
            {i18n.t("contacts.buttons.import")}
          </Button>
          <Button
            variant="contained"
            color="primary"
            onClick={handleOpenContactModal}
          >
            {i18n.t("contacts.buttons.add")}
          </Button>
        </MainHeaderButtonsWrapper>
      </MainHeader>
      <Paper
        className={classes.mainPaper}
        variant="outlined"
        onScroll={handleScroll}
      >
        <Table size="small">
          <TableHead>
            <TableRow>
              <TableCell padding="checkbox" />
              <TableCell>{i18n.t("contacts.table.name")}</TableCell>
              <TableCell align="center">
                {i18n.t("contacts.table.whatsapp")}
              </TableCell>
              <TableCell align="center">
                {i18n.t("contacts.table.email")}
              </TableCell>
              <TableCell align="center">
                Messenger ID
              </TableCell>
              <TableCell align="center">
                Instagram ID
              </TableCell>
              <TableCell align="center">
                {i18n.t("contacts.table.actions")}
              </TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            <>
              {contacts.map((contact) => (
                <TableRow key={contact.id}>
                  <TableCell style={{ paddingRight: 0 }}>
                    {<Avatar src={contact.profilePicUrl} />}
                  </TableCell>
                  <TableCell>{contact.name}</TableCell>
                  <TableCell align="center">{contact.number}</TableCell>
                  <TableCell align="center">{contact.email}</TableCell>
                  <TableCell align="center">{contact.messengerId}</TableCell>
                  <TableCell align="center">{contact.instagramId}</TableCell>
                  <TableCell align="center">
                    {contact.number && (
                      <IconButton
                        size="small"
                        onClick={() => handleSaveTicket(contact.id)}
                      >
                        <WhatsAppIcon />
                      </IconButton>
                    )}
                    {!contact.number && !contact.instagramId && (
                      <IconButton
                        size="small"
                        onClick={() => handleSaveTicket(contact.id)}
                      >
                        <FacebookIcon />
                      </IconButton>
                    )}
                     {!contact.number && !contact.messengerId && (
                      <IconButton
                        size="small"
                        onClick={() => handleSaveTicket(contact.id)}
                      >
                        <InstagramIcon />
                      </IconButton>
                    )}
                    <IconButton
                      size="small"
                      onClick={() => hadleEditContact(contact.id)}
                    >
                      <EditIcon />
                    </IconButton>
                    <Can
                      role={user.profile}
                      perform="contacts-page:deleteContact"
                      yes={() => (
                        <IconButton
                          size="small"
                          onClick={(e) => {
                            setConfirmOpen(true);
                            setDeletingContact(contact);
                          }}
                        >
                          <DeleteOutlineIcon />
                        </IconButton>
                      )}
                    />
                  </TableCell>
                </TableRow>
              ))}
              {loading && <TableRowSkeleton avatar columns={3} />}
            </>
          </TableBody>
        </Table>
      </Paper>
    </MainContainer>
  );
};

export default Contacts;

frontend\src\pages\Settings\index.js​


Código:
import React, { useState, useEffect } from "react";
import openSocket from "../../services/socket-io";

import { makeStyles } from "@material-ui/core/styles";
import Paper from "@material-ui/core/Paper";
import Typography from "@material-ui/core/Typography";
import Container from "@material-ui/core/Container";
import Select from "@material-ui/core/Select";
import TextField from "@material-ui/core/TextField";
import { toast } from "react-toastify";

import api from "../../services/api";
import { i18n } from "../../translate/i18n.js";
import toastError from "../../errors/toastError";

const useStyles = makeStyles(theme => ({
    root: {
        display: "flex",
        alignItems: "center",
        padding: theme.spacing(8, 8, 3),
    },

    paper: {
        padding: theme.spacing(2),
        display: "flex",
        alignItems: "center",
        marginBottom: 12,

    },

    settingOption: {
        marginLeft: "auto",
    },
    margin: {
        margin: theme.spacing(1),
    },

}));

const Settings = () => {
    const classes = useStyles();

    const [settings, setSettings] = useState([]);

    useEffect(() => {
        const fetchSession = async () => {
            try {
                const { data } = await api.get("/settings");
                setSettings(data);
            } catch (err) {
                toastError(err);
            }
        };
        fetchSession();
    }, []);

    useEffect(() => {
        const socket = openSocket();

        socket.on("settings", data => {
            if (data.action === "update") {
                setSettings(prevState => {
                    const aux = [...prevState];
                    const settingIndex = aux.findIndex(s => s.key === data.setting.key);
                    aux[settingIndex].value = data.setting.value;
                    return aux;
                });
            }
        });

        return () => {
            socket.disconnect();
        };
    }, []);

    const handleChangeSetting = async e => {
        const selectedValue = e.target.value;
        const settingKey = e.target.name;

        try {
            await api.put(`/settings/${settingKey}`, {
                value: selectedValue,
            });
            toast.success(i18n.t("settings.success"));
        } catch (err) {
            toastError(err);
        }
    };

    const getSettingValue = key => {
        const setting = settings.find(s => s.key === key);
        return setting ? setting.value : "";
    };

    return (
        <div className={classes.root}>
            <Container className={classes.container} maxWidth="sm">
                <Typography variant="body2" gutterBottom>
                    {i18n.t("settings.title")}
                </Typography>

                <Paper className={classes.paper}>
                    <Typography variant="body1">
                        {i18n.t("settings.settings.userCreation.name")}
                    </Typography>
                    <Select
                        margin="dense"
                        variant="outlined"
                        native
                        id="userCreation-setting"
                        name="userCreation"
                        value={
                            settings && settings.length > 0 && getSettingValue("userCreation")
                        }
                        className={classes.settingOption}
                        onChange={handleChangeSetting}
                    >
                        <option value="enabled">
                            {i18n.t("settings.settings.userCreation.options.enabled")}
                        </option>
                        <option value="disabled">
                            {i18n.t("settings.settings.userCreation.options.disabled")}
                        </option>
                    </Select>
                </Paper>

                <Paper className={classes.paper}>
                    <TextField
                        id="api-token-setting"
                        readonly
                        label="Token Api"
                        margin="dense"
                        variant="outlined"
                        fullWidth
                        value={settings && settings.length > 0 && getSettingValue("userApiToken")}
                    />
                </Paper>

                {/* Novo campo para hubToken */}
                <Paper className={classes.paper}>
                    <TextField
                        id="hub-token-setting"
                        label="Hub Token"
                        name="hubToken"
                        margin="dense"
                        variant="outlined"
                        fullWidth
                        value={settings && settings.length > 0 && getSettingValue("hubToken")}
                        onChange={handleChangeSetting}
                        InputProps={{
                            style: { filter: "blur(5px)" },
                            readOnly: true
                        }}
                    />
                </Paper>
            </Container>
        </div>
    );
};

export default Settings;
 
Última edição:
shape1
shape2
shape3
shape4
shape7
shape8
Top