mirror of
https://github.com/cheveguerra/whaticket-community.git
synced 2026-04-18 03:39:29 +00:00
Merge pull request #10 from canove/test
added multiple WhatsApp account support
This commit is contained in:
101
README.md
101
README.md
@@ -4,7 +4,7 @@ A _very simple_ Ticket System based on WhatsApp messages.
|
||||
|
||||
Backend uses [whatsapp-web.js](https://github.com/pedroslopez/whatsapp-web.js) to receive and send WhatsApp messages, create tickets from them and store all in a MySQL database.
|
||||
|
||||
Frontend is a full-featured multi-user _chat app_ bootstrapped with react-create-app and Material UI, that comunicates with backend using REST API and Websockets. It allows you to interact with contacts, tickets, send and receive WhatsApp messagees.
|
||||
Frontend is a full-featured multi-user _chat app_ bootstrapped with react-create-app and Material UI, that comunicates with backend using REST API and Websockets. It allows you to interact with contacts, tickets, send and receive WhatsApp messages.
|
||||
|
||||
**NOTE**: I can't guarantee you will not be blocked by using this method, although it has worked for me. WhatsApp does not allow bots or unofficial clients on their platform, so this shouldn't be considered totally safe.
|
||||
|
||||
@@ -22,13 +22,22 @@ If a contact sent a new message in less than 2 hours interval, and there is no t
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="https://raw.githubusercontent.com/canove/whaticket/master/images/chat2.png" width="350"> <img src="https://raw.githubusercontent.com/canove/whaticket/master/images/chat3.png" width="350">
|
||||
<img src="https://raw.githubusercontent.com/canove/whaticket/master/images/tickets2.png" width="350"> <img src="https://raw.githubusercontent.com/canove/whaticket/master/images/tickets3.png" width="350"> <img src="https://raw.githubusercontent.com/canove/whaticket/master/images/multiple-whatsapps2.png" width="350">
|
||||
|
||||
## Features
|
||||
|
||||
- Have multiple users chating in same WhatsApp Number ✅
|
||||
- Connect to multiple WhatsApp accounts and receive all messages in one place.
|
||||
- Create and chat with new contacts without touching cellphone ✅
|
||||
- Send and receive message ✅
|
||||
- Send media (images/audio/documents) ✅
|
||||
- Receive media (images/audio/video/documents) ✅
|
||||
|
||||
## Demo
|
||||
|
||||
**Note**: It's not a good idea to sync your whatsapp account is this demo enviroment, because all your received messages will be stored in database and will be accessible by everyone that access this URL and creates an account.
|
||||
**Note**: It's not a good idea to sync your WhatsApp account is this demo enviroment, because all your received messages will be stored in database and will be accessible by everyone that access this URL and creates an account.
|
||||
|
||||
That said, theres not much to test without syncing an whatsapp account, since adding contacts or tickets simple throws an error if app is not synced with whatassp. I will create a better test enviroment in future.
|
||||
That said, theres not much to test without syncing an WhatsApp account, since adding contacts or tickets simple throws an error if app is not synced with WhatsApp.
|
||||
|
||||
Meanwhile, if you want to test it, remember to disconnect session and delete all tickets and contacts after your tests.
|
||||
|
||||
@@ -38,12 +47,12 @@ email: demo@demo.com
|
||||
|
||||
password: demo123
|
||||
|
||||
It's online thanks to [@ramphy](https://github.com/ramphy), who provided a VPS for me to create these installation instructions.
|
||||
It's online thanks to [@ramphy](https://github.com/ramphy), who provided a VPS to creation of these installation instructions.
|
||||
|
||||
## Installation and Usage (Linux Ubuntu - Development)
|
||||
|
||||
Create Mysql Database using docker:
|
||||
_Note_: change dbname, username password.
|
||||
_Note_: change MYSQL_DATABASE, MYSQL_PASSWORD, MYSQL_USER and MYSQL_ROOT_PASSWORD.
|
||||
|
||||
```bash
|
||||
docker run --name whaticketdb -e MYSQL_ROOT_PASSWORD=strongpassword -e MYSQL_DATABASE=whaticket -e MYSQL_USER=whaticket -e MYSQL_PASSWORD=whaticket --restart always -p 3306:3306 -d mariadb:latest --character-set-server=utf8mb4 --collation-server=utf8mb4_bin
|
||||
@@ -57,9 +66,10 @@ sudo apt-get install -y libgbm-dev wget unzip fontconfig locales gconf-service l
|
||||
|
||||
- Clone this repo
|
||||
- On backend folder:
|
||||
- Copy .env.example to .env and fill it with database details
|
||||
- Copy .env.example to .env and fill it with database details.
|
||||
- Install dependecies: `npm install` (Only in the first time)
|
||||
- Create database tables: `npx sequelize db:migrate` (Only in the first time)
|
||||
- Fill database with initial values: `npx sequelize db:seed:all`
|
||||
- Start backend: `npm start`
|
||||
- In another terminal, on frontend folder:
|
||||
- Copy .env.example to .env and fill it with backend URL (normally localhost:port)
|
||||
@@ -67,12 +77,13 @@ sudo apt-get install -y libgbm-dev wget unzip fontconfig locales gconf-service l
|
||||
- Start frontend: `npm start`
|
||||
- Go to http://your_server_ip:3000/signup
|
||||
- Create an user and login with it.
|
||||
- On the sidebard, go to _Connection_ and read QRCode with your WhatsApp.
|
||||
- On the sidebard, go to _Connections_ and create your first WhatsApp connection.
|
||||
- Wait for QR CODE button to appear, click it and read qr code.
|
||||
- Done. Every message received by your synced WhatsApp number will appear in Tickets List.
|
||||
|
||||
## Basic production deployment (Ubuntu 18.04 VPS)
|
||||
|
||||
You need two subdomains forwarding to youts VPS ip to follow these instructions. We'll use `myapp.mydomain.com` and `api.mydomain.com` in examples.
|
||||
You'll need two subdomains forwarding to yours VPS ip to follow these instructions. We'll use `myapp.mydomain.com` to frontend and `api.mydomain.com` to backend in the following example. We'll also use an dedicated user with sudo privileges no deploy it (not root).
|
||||
|
||||
Update all system packages:
|
||||
|
||||
@@ -89,7 +100,7 @@ node -v
|
||||
npm -v
|
||||
```
|
||||
|
||||
Install docker:
|
||||
Install docker and add you user to docker group:
|
||||
|
||||
```bash
|
||||
sudo apt install apt-transport-https ca-certificates curl software-properties-common
|
||||
@@ -98,20 +109,21 @@ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubun
|
||||
sudo apt update
|
||||
sudo apt install docker-ce
|
||||
sudo systemctl status docker
|
||||
```
|
||||
|
||||
Add current user to docker group:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker \${USER}
|
||||
su - \${USER}
|
||||
```
|
||||
|
||||
Create database container (Instructions in installation)
|
||||
Create Mysql Database using docker:
|
||||
_Note_: change MYSQL_DATABASE, MYSQL_PASSWORD, MYSQL_USER and MYSQL_ROOT_PASSWORD.
|
||||
|
||||
```bash
|
||||
docker run --name whaticketdb -e MYSQL_ROOT_PASSWORD=strongpassword -e MYSQL_DATABASE=whaticket -e MYSQL_USER=whaticket -e MYSQL_PASSWORD=whaticket --restart always -p 3306:3306 -d mariadb:latest --character-set-server=utf8mb4 --collation-server=utf8mb4_bin
|
||||
```
|
||||
|
||||
Clone this repository:
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/canove/whaticket whaticket
|
||||
```
|
||||
|
||||
@@ -124,44 +136,44 @@ nano whaticket/backend/.env
|
||||
|
||||
```bash
|
||||
NODE_ENV=
|
||||
BACKEND_URL=https://api.mydomain.com
|
||||
PROXY_PORT=443
|
||||
BACKEND_URL=https://api.mydomain.com #USE HTTPS HERE, WE WILL ADD SSL LATTER
|
||||
PROXY_PORT=443 #USE NGINX REVERSE PROXY PORT HERE, WE WILL CONFIGURE IT LATTER
|
||||
PORT=8080
|
||||
|
||||
DB_HOST=
|
||||
DB_HOST=localhost
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
DB_NAME=
|
||||
```
|
||||
|
||||
Install puppeteer dependencies(Instructions in installation)
|
||||
Install puppeteer dependencies:
|
||||
|
||||
Install backend dependencies and run migrations:
|
||||
```bash
|
||||
sudo apt-get install -y libgbm-dev wget unzip fontconfig locales gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils
|
||||
```
|
||||
|
||||
Install backend dependencies, run migrations and seeds:
|
||||
|
||||
```bash
|
||||
cd whaticket/backend
|
||||
npm install
|
||||
npx sequelize db:migrate
|
||||
npx sequelize db:seed:all
|
||||
```
|
||||
|
||||
Start backend to confirm its working, you should see: `Server started on port...` on console.
|
||||
Start it with `npm start`, you should see: `Server started on port...` on console. Hit `CTRL + C` to exit.
|
||||
|
||||
Install pm2 **with sudo**:
|
||||
Install pm2 **with sudo**, and start backend with it:
|
||||
|
||||
```bash
|
||||
sudo npm install -g pm2
|
||||
```
|
||||
|
||||
Start backend with pm2:
|
||||
|
||||
```bash
|
||||
pm2 start src/app.js --name whaticket-backend
|
||||
```
|
||||
|
||||
Make pm2 auto start afeter reboot:
|
||||
|
||||
```bash
|
||||
pm2 startup ubuntu -u YOUR_USERNAME
|
||||
pm2 startup ubuntu -u `YOUR_USERNAME`
|
||||
```
|
||||
|
||||
Copy the last line outputed from previus command and run it, its something like:
|
||||
@@ -170,10 +182,10 @@ Copy the last line outputed from previus command and run it, its something like:
|
||||
sudo env PATH=\$PATH:/usr/bin pm2 startup ubuntu -u YOUR_USERNAME --hp /home/YOUR_USERNAM
|
||||
```
|
||||
|
||||
Now, lets prepare frontend:
|
||||
Go to frontend folder and install dependencies:
|
||||
|
||||
```bash
|
||||
cd ../whaticket/frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
@@ -189,16 +201,24 @@ Build frontend app:
|
||||
npm run build
|
||||
```
|
||||
|
||||
Start frotend with pm2:
|
||||
Start frontend with pm2, and save pm2 process list to start automatically after reboot:
|
||||
|
||||
```bash
|
||||
pm2 start server.js --name whaticket-frontend
|
||||
pm2 save
|
||||
```
|
||||
|
||||
Save pm2 process list to start automatically after reboot:
|
||||
To check if it's running, run `pm2 list`, it should look like:
|
||||
|
||||
```bash
|
||||
pm2 save
|
||||
deploy@ubuntu-whats:~$ pm2 list
|
||||
┌─────┬─────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
|
||||
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ . │ status │ cpu │ mem │ user │ watching │
|
||||
├─────┼─────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
|
||||
│ 1 │ whaticket-frontend │ default │ 0.1.0 │ fork │ 179249 │ 12D │ 0 │ online │ 0.3% │ 50.2mb │ deploy │ disabled │
|
||||
│ 6 │ whaticket-backend │ default │ 1.0.0 │ fork │ 179253 │ 12D │ 15 │ online │ 0.3% │ 118.5mb │ deploy │ disabled │
|
||||
└─────┴─────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
|
||||
|
||||
```
|
||||
|
||||
Install nginx:
|
||||
@@ -211,7 +231,6 @@ Remove nginx default site:
|
||||
|
||||
```bash
|
||||
sudo rm /etc/nginx/sites-enabled/default
|
||||
sudo service nginx restart
|
||||
```
|
||||
|
||||
Create a new nginx site to frontend app:
|
||||
@@ -240,7 +259,7 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Create another one to backend api, changing `server_name` to yours equivalent to `api.mydomain.com`, and `proxy_pass` to you localhost backend node server URL:
|
||||
Create another one to backend api, changing `server_name` to yours equivalent to `api.mydomain.com`, and `proxy_pass` to your localhost backend node server URL:
|
||||
|
||||
```bash
|
||||
sudo cp /etc/nginx/sites-available/whaticket-frontend /etc/nginx/sites-available/whaticket-backend
|
||||
@@ -257,7 +276,7 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Create a symbolic link to enalbe nginx site:
|
||||
Create a symbolic links to enalbe nginx sites:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/whaticket-frontend /etc/nginx/sites-enabled
|
||||
@@ -285,14 +304,6 @@ Enable SSL on nginx (Accept all information asked):
|
||||
sudo certbot --nginx
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Have multiple users chating in same WhatsApp Number ✅
|
||||
- Create and chat with new contacts without touching cellphone ✅
|
||||
- Send and receive message ✅
|
||||
- Send media (images/audio/documents) ✅
|
||||
- Receive media (images/audio/video/documents) ✅
|
||||
|
||||
## Contributing
|
||||
|
||||
Any help and suggestions are welcome!
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-validator": "^6.5.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"multer": "^1.4.2",
|
||||
"mysql2": "^2.1.0",
|
||||
@@ -30,7 +29,8 @@
|
||||
"sequelize": "^6.3.4",
|
||||
"socket.io": "^2.3.0",
|
||||
"whatsapp-web.js": "^1.8.0",
|
||||
"youch": "^2.0.10"
|
||||
"youch": "^2.0.10",
|
||||
"yup": "^0.29.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.4",
|
||||
|
||||
@@ -7,16 +7,12 @@ const cors = require("cors");
|
||||
const multer = require("multer");
|
||||
const Sentry = require("@sentry/node");
|
||||
|
||||
const wBot = require("./libs/wbot");
|
||||
const { initWbot } = require("./libs/wbot");
|
||||
const wbotMessageListener = require("./services/wbotMessageListener");
|
||||
const wbotMonitor = require("./services/wbotMonitor");
|
||||
const Whatsapp = require("./models/Whatsapp");
|
||||
|
||||
const MessagesRoutes = require("./routes/messages");
|
||||
const ContactsRoutes = require("./routes/contacts");
|
||||
const AuthRoutes = require("./routes/auth");
|
||||
const TicketsRoutes = require("./routes/tickets");
|
||||
const WhatsRoutes = require("./routes/whatsapp");
|
||||
const UsersRoutes = require("./routes/users");
|
||||
const Router = require("./router");
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -40,13 +36,7 @@ app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(multer({ storage: fileStorage }).single("media"));
|
||||
app.use("/public", express.static(path.join(__dirname, "..", "public")));
|
||||
|
||||
app.use("/auth", AuthRoutes);
|
||||
app.use(ContactsRoutes);
|
||||
app.use(TicketsRoutes);
|
||||
app.use(MessagesRoutes);
|
||||
app.use(WhatsRoutes);
|
||||
app.use(UsersRoutes);
|
||||
app.use(Router);
|
||||
|
||||
const io = require("./libs/socket").init(server);
|
||||
io.on("connection", socket => {
|
||||
@@ -66,13 +56,21 @@ io.on("connection", socket => {
|
||||
});
|
||||
});
|
||||
|
||||
wBot
|
||||
.init()
|
||||
.then(({ dbSession }) => {
|
||||
wbotMessageListener();
|
||||
wbotMonitor(dbSession);
|
||||
})
|
||||
.catch(err => console.log(err));
|
||||
const startWhatsAppSessions = async () => {
|
||||
const whatsapps = await Whatsapp.findAll();
|
||||
|
||||
if (whatsapps.length > 0) {
|
||||
whatsapps.forEach(whatsapp => {
|
||||
initWbot(whatsapp)
|
||||
.then(() => {
|
||||
wbotMessageListener(whatsapp);
|
||||
wbotMonitor(whatsapp);
|
||||
})
|
||||
.catch(err => console.log(err));
|
||||
});
|
||||
}
|
||||
};
|
||||
startWhatsAppSessions();
|
||||
|
||||
app.use(Sentry.Handlers.errorHandler());
|
||||
|
||||
@@ -82,6 +80,6 @@ app.use(async (err, req, res, next) => {
|
||||
console.log(err);
|
||||
return res.status(500).json(errors);
|
||||
}
|
||||
console.log(err);
|
||||
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
|
||||
@@ -12,4 +12,5 @@ module.exports = {
|
||||
username: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
logging: false,
|
||||
seederStorage: "sequelize",
|
||||
};
|
||||
|
||||
@@ -2,13 +2,14 @@ const Sequelize = require("sequelize");
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
const Contact = require("../models/Contact");
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
const ContactCustomField = require("../models/ContactCustomField");
|
||||
|
||||
const { getIO } = require("../libs/socket");
|
||||
const { getWbot } = require("../libs/wbot");
|
||||
|
||||
exports.index = async (req, res) => {
|
||||
const { searchParam = "", pageNumber = 1, rowsPerPage = 10 } = req.query;
|
||||
const { searchParam = "", pageNumber = 1 } = req.query;
|
||||
|
||||
const whereCondition = {
|
||||
[Op.or]: [
|
||||
@@ -23,7 +24,7 @@ exports.index = async (req, res) => {
|
||||
],
|
||||
};
|
||||
|
||||
let limit = +rowsPerPage;
|
||||
let limit = 20;
|
||||
let offset = limit * (pageNumber - 1);
|
||||
|
||||
const { count, rows: contacts } = await Contact.findAndCountAll({
|
||||
@@ -33,31 +34,42 @@ exports.index = async (req, res) => {
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
return res.json({ contacts, count });
|
||||
const hasMore = count > offset + contacts.length;
|
||||
|
||||
return res.json({ contacts, count, hasMore });
|
||||
};
|
||||
|
||||
exports.store = async (req, res) => {
|
||||
const wbot = getWbot();
|
||||
const defaultWhatsapp = await Whatsapp.findOne({
|
||||
where: { default: true },
|
||||
});
|
||||
|
||||
if (!defaultWhatsapp) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "No default WhatsApp found. Check Connection page." });
|
||||
}
|
||||
|
||||
const wbot = getWbot(defaultWhatsapp);
|
||||
const io = getIO();
|
||||
const newContact = req.body;
|
||||
|
||||
let isValidNumber;
|
||||
|
||||
try {
|
||||
isValidNumber = await wbot.isRegisteredUser(`${newContact.number}@c.us`);
|
||||
const isValidNumber = await wbot.isRegisteredUser(
|
||||
`${newContact.number}@c.us`
|
||||
);
|
||||
if (!isValidNumber) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "The suplied number is not a valid Whatsapp number" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Could not check whatsapp contact. Is session details valid?");
|
||||
console.log(err);
|
||||
return res.status(500).json({
|
||||
error: "Could not check whatsapp contact. Check connection page.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!isValidNumber) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "The suplied number is not a valid Whatsapp number" });
|
||||
}
|
||||
|
||||
const profilePicUrl = await wbot.getProfilePicUrl(
|
||||
`${newContact.number}@c.us`
|
||||
);
|
||||
@@ -74,26 +86,22 @@ exports.store = async (req, res) => {
|
||||
contact: contact,
|
||||
});
|
||||
|
||||
res.status(200).json(contact);
|
||||
return res.status(200).json(contact);
|
||||
};
|
||||
|
||||
exports.show = async (req, res) => {
|
||||
const { contactId } = req.params;
|
||||
|
||||
const { id, name, number, email, extraInfo } = await Contact.findByPk(
|
||||
contactId,
|
||||
{
|
||||
include: "extraInfo",
|
||||
}
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
id,
|
||||
name,
|
||||
number,
|
||||
email,
|
||||
extraInfo,
|
||||
const contact = await Contact.findByPk(contactId, {
|
||||
include: "extraInfo",
|
||||
attributes: ["id", "name", "number", "email"],
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return res.status(404).json({ error: "No contact found with this id." });
|
||||
}
|
||||
|
||||
return res.status(200).json(contact);
|
||||
};
|
||||
|
||||
exports.update = async (req, res) => {
|
||||
@@ -108,7 +116,7 @@ exports.update = async (req, res) => {
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return res.status(400).json({ error: "No contact found with this ID" });
|
||||
return res.status(404).json({ error: "No contact found with this ID" });
|
||||
}
|
||||
|
||||
if (updatedContact.extraInfo) {
|
||||
@@ -138,7 +146,7 @@ exports.update = async (req, res) => {
|
||||
contact: contact,
|
||||
});
|
||||
|
||||
res.status(200).json(contact);
|
||||
return res.status(200).json(contact);
|
||||
};
|
||||
|
||||
exports.delete = async (req, res) => {
|
||||
@@ -148,7 +156,7 @@ exports.delete = async (req, res) => {
|
||||
const contact = await Contact.findByPk(contactId);
|
||||
|
||||
if (!contact) {
|
||||
return res.status(400).json({ error: "No contact found with this ID" });
|
||||
return res.status(404).json({ error: "No contact found with this ID" });
|
||||
}
|
||||
|
||||
await contact.destroy();
|
||||
@@ -158,5 +166,5 @@ exports.delete = async (req, res) => {
|
||||
contactId: contactId,
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "Contact deleted" });
|
||||
return res.status(200).json({ message: "Contact deleted" });
|
||||
};
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
const Contact = require("../models/Contact");
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
const { getIO } = require("../libs/socket");
|
||||
const { getWbot, init } = require("../libs/wbot");
|
||||
const { getWbot, initWbot } = require("../libs/wbot");
|
||||
|
||||
exports.store = async (req, res, next) => {
|
||||
const io = getIO();
|
||||
const wbot = getWbot();
|
||||
const defaultWhatsapp = await Whatsapp.findOne({
|
||||
where: { default: true },
|
||||
});
|
||||
|
||||
const phoneContacts = await wbot.getContacts();
|
||||
if (!defaultWhatsapp) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "No default WhatsApp found. Check Connection page." });
|
||||
}
|
||||
|
||||
const io = getIO();
|
||||
const wbot = getWbot(defaultWhatsapp);
|
||||
|
||||
let phoneContacts;
|
||||
|
||||
try {
|
||||
phoneContacts = await wbot.getContacts();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return res.status(500).json({
|
||||
error: "Could not check whatsapp contact. Check connection page.",
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
phoneContacts.map(async ({ number, name }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const Message = require("../models/Message");
|
||||
const Contact = require("../models/Contact");
|
||||
const User = require("../models/User");
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
|
||||
const Ticket = require("../models/Ticket");
|
||||
const { getIO } = require("../libs/socket");
|
||||
@@ -11,7 +12,7 @@ const { MessageMedia } = require("whatsapp-web.js");
|
||||
|
||||
const setMessagesAsRead = async ticket => {
|
||||
const io = getIO();
|
||||
const wbot = getWbot();
|
||||
const wbot = getWbot(ticket.whatsappId);
|
||||
|
||||
await Message.update(
|
||||
{ read: true },
|
||||
@@ -38,9 +39,6 @@ const setMessagesAsRead = async ticket => {
|
||||
};
|
||||
|
||||
exports.index = async (req, res, next) => {
|
||||
// const wbot = getWbot();
|
||||
// const io = getIO();
|
||||
|
||||
const { ticketId } = req.params;
|
||||
const { searchParam = "", pageNumber = 1 } = req.query;
|
||||
|
||||
@@ -107,7 +105,6 @@ exports.index = async (req, res, next) => {
|
||||
};
|
||||
|
||||
exports.store = async (req, res, next) => {
|
||||
const wbot = getWbot();
|
||||
const io = getIO();
|
||||
|
||||
const { ticketId } = req.params;
|
||||
@@ -125,6 +122,26 @@ exports.store = async (req, res, next) => {
|
||||
],
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: "No ticket found with this ID" });
|
||||
}
|
||||
|
||||
if (!ticket.whatsappId) {
|
||||
const defaultWhatsapp = await Whatsapp.findOne({
|
||||
where: { default: true },
|
||||
});
|
||||
|
||||
if (!defaultWhatsapp) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "No default WhatsApp found. Check Connection page." });
|
||||
}
|
||||
|
||||
await ticket.setWhatsapp(defaultWhatsapp);
|
||||
}
|
||||
|
||||
const wbot = getWbot(ticket.whatsappId);
|
||||
|
||||
try {
|
||||
if (media) {
|
||||
const newMedia = MessageMedia.fromFilePath(req.file.path);
|
||||
@@ -177,7 +194,7 @@ exports.store = async (req, res, next) => {
|
||||
|
||||
await setMessagesAsRead(ticket);
|
||||
|
||||
return res.json({ newMessage, ticket });
|
||||
return res.status(200).json({ newMessage, ticket });
|
||||
}
|
||||
|
||||
return res
|
||||
|
||||
@@ -8,7 +8,7 @@ exports.store = async (req, res, next) => {
|
||||
|
||||
const user = await User.findOne({ where: { email: email } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "No user found with this email" });
|
||||
return res.status(404).json({ error: "No user found with this email" });
|
||||
}
|
||||
|
||||
if (!(await user.checkPassword(password))) {
|
||||
@@ -23,7 +23,10 @@ exports.store = async (req, res, next) => {
|
||||
}
|
||||
);
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ token: token, username: user.name, userId: user.id });
|
||||
return res.status(200).json({
|
||||
token: token,
|
||||
username: user.name,
|
||||
profile: user.profile,
|
||||
userId: user.id,
|
||||
});
|
||||
};
|
||||
|
||||
39
backend/src/controllers/SettingController.js
Normal file
39
backend/src/controllers/SettingController.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const Setting = require("../models/Setting");
|
||||
const { getIO } = require("../libs/socket");
|
||||
|
||||
exports.index = async (req, res) => {
|
||||
if (req.user.profile !== "admin") {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only administrators can access this route." });
|
||||
}
|
||||
|
||||
const settings = await Setting.findAll();
|
||||
|
||||
return res.status(200).json(settings);
|
||||
};
|
||||
|
||||
exports.update = async (req, res) => {
|
||||
if (req.user.profile !== "admin") {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only administrators can access this route." });
|
||||
}
|
||||
|
||||
const io = getIO();
|
||||
const { settingKey } = req.params;
|
||||
const setting = await Setting.findByPk(settingKey);
|
||||
|
||||
if (!setting) {
|
||||
return res.status(404).json({ error: "No setting found with this ID" });
|
||||
}
|
||||
|
||||
await setting.update(req.body);
|
||||
|
||||
io.emit("settings", {
|
||||
action: "update",
|
||||
setting,
|
||||
});
|
||||
|
||||
return res.status(200).json(setting);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const { startOfDay, endOfDay, parseISO } = require("date-fns");
|
||||
const Ticket = require("../models/Ticket");
|
||||
const Contact = require("../models/Contact");
|
||||
const Message = require("../models/Message");
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
|
||||
const { getIO } = require("../libs/socket");
|
||||
|
||||
@@ -16,7 +17,7 @@ exports.index = async (req, res) => {
|
||||
showAll,
|
||||
} = req.query;
|
||||
|
||||
const userId = req.userId;
|
||||
const userId = req.user.id;
|
||||
|
||||
const limit = 20;
|
||||
const offset = limit * (pageNumber - 1);
|
||||
@@ -105,12 +106,23 @@ exports.index = async (req, res) => {
|
||||
|
||||
const hasMore = count > offset + tickets.length;
|
||||
|
||||
return res.json({ count, tickets, hasMore });
|
||||
return res.status(200).json({ count, tickets, hasMore });
|
||||
};
|
||||
|
||||
exports.store = async (req, res) => {
|
||||
const io = getIO();
|
||||
const ticket = await Ticket.create(req.body);
|
||||
|
||||
const defaultWhatsapp = await Whatsapp.findOne({
|
||||
where: { default: true },
|
||||
});
|
||||
|
||||
if (!defaultWhatsapp) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "No default WhatsApp found. Check Connection page." });
|
||||
}
|
||||
|
||||
const ticket = await defaultWhatsapp.createTicket(req.body);
|
||||
|
||||
const contact = await ticket.getContact();
|
||||
|
||||
@@ -121,7 +133,7 @@ exports.store = async (req, res) => {
|
||||
ticket: serializaedTicket,
|
||||
});
|
||||
|
||||
res.status(200).json(ticket);
|
||||
return res.status(200).json(ticket);
|
||||
};
|
||||
|
||||
exports.update = async (req, res) => {
|
||||
@@ -139,7 +151,7 @@ exports.update = async (req, res) => {
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(400).json({ error: "No ticket found with this ID" });
|
||||
return res.status(404).json({ error: "No ticket found with this ID" });
|
||||
}
|
||||
|
||||
await ticket.update(req.body);
|
||||
@@ -149,7 +161,7 @@ exports.update = async (req, res) => {
|
||||
ticket: ticket,
|
||||
});
|
||||
|
||||
res.status(200).json(ticket);
|
||||
return res.status(200).json(ticket);
|
||||
};
|
||||
|
||||
exports.delete = async (req, res) => {
|
||||
@@ -169,5 +181,5 @@ exports.delete = async (req, res) => {
|
||||
ticketId: ticket.id,
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "ticket deleted" });
|
||||
return res.status(200).json({ message: "ticket deleted" });
|
||||
};
|
||||
|
||||
@@ -1,48 +1,161 @@
|
||||
const { validationResult } = require("express-validator");
|
||||
const Sequelize = require("sequelize");
|
||||
const Yup = require("yup");
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
const User = require("../models/User");
|
||||
const Setting = require("../models/Setting");
|
||||
|
||||
const { getIO } = require("../libs/socket");
|
||||
|
||||
exports.index = async (req, res) => {
|
||||
// const { searchParam = "", pageNumber = 1 } = req.query;
|
||||
if (req.user.profile !== "admin") {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only administrators can access this route." });
|
||||
}
|
||||
|
||||
const users = await User.findAll({ attributes: ["name", "id", "email"] });
|
||||
const { searchParam = "", pageNumber = 1 } = req.query;
|
||||
|
||||
return res.status(200).json(users);
|
||||
const whereCondition = {
|
||||
[Op.or]: [
|
||||
{
|
||||
name: Sequelize.where(
|
||||
Sequelize.fn("LOWER", Sequelize.col("name")),
|
||||
"LIKE",
|
||||
"%" + searchParam.toLowerCase() + "%"
|
||||
),
|
||||
},
|
||||
{ email: { [Op.like]: `%${searchParam.toLowerCase()}%` } },
|
||||
],
|
||||
};
|
||||
|
||||
let limit = 20;
|
||||
let offset = limit * (pageNumber - 1);
|
||||
|
||||
const { count, rows: users } = await User.findAndCountAll({
|
||||
attributes: ["name", "id", "email", "profile"],
|
||||
where: whereCondition,
|
||||
limit,
|
||||
offset,
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
const hasMore = count > offset + users.length;
|
||||
|
||||
return res.status(200).json({ users, count, hasMore });
|
||||
};
|
||||
|
||||
exports.store = async (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
console.log(req.url);
|
||||
const schema = Yup.object().shape({
|
||||
name: Yup.string().required().min(2),
|
||||
email: Yup.string()
|
||||
.email()
|
||||
.required()
|
||||
.test(
|
||||
"Check-email",
|
||||
"An user with this email already exists",
|
||||
async value => {
|
||||
const userFound = await User.findOne({ where: { email: value } });
|
||||
return !Boolean(userFound);
|
||||
}
|
||||
),
|
||||
password: Yup.string().required().min(5),
|
||||
});
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
if (req.url === "/signup") {
|
||||
const { value: userCreation } = await Setting.findByPk("userCreation");
|
||||
|
||||
if (userCreation === "disabled") {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "User creation is disabled by administrator." });
|
||||
}
|
||||
} else if (req.user.profile !== "admin") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Validation failed", data: errors.array() });
|
||||
.status(403)
|
||||
.json({ error: "Only administrators can create users." });
|
||||
}
|
||||
|
||||
const { name, id, email } = await User.create(req.body);
|
||||
try {
|
||||
await schema.validate(req.body);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
res.status(201).json({ message: "User created!", userId: id });
|
||||
const io = getIO();
|
||||
|
||||
const { name, id, email, profile } = await User.create(req.body);
|
||||
|
||||
io.emit("user", {
|
||||
action: "create",
|
||||
user: { name, id, email, profile },
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: "User created!", userId: id });
|
||||
};
|
||||
|
||||
exports.update = async (req, res) => {
|
||||
exports.show = async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
attributes: ["name", "id", "email"],
|
||||
attributes: ["id", "name", "email", "profile"],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: "No user found with this id." });
|
||||
}
|
||||
|
||||
return res.status(200).json(user);
|
||||
};
|
||||
|
||||
exports.update = async (req, res) => {
|
||||
const schema = Yup.object().shape({
|
||||
name: Yup.string().min(2),
|
||||
email: Yup.string().email(),
|
||||
password: Yup.string(),
|
||||
});
|
||||
|
||||
if (req.user.profile !== "admin") {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only administrators can edit users." });
|
||||
}
|
||||
|
||||
await schema.validate(req.body);
|
||||
|
||||
const io = getIO();
|
||||
const { userId } = req.params;
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
attributes: ["name", "id", "email", "profile"],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "No user found with this id." });
|
||||
}
|
||||
|
||||
if (user.profile === "admin" && req.body.profile === "user") {
|
||||
const adminUsers = await User.count({ where: { profile: "admin" } });
|
||||
if (adminUsers <= 1) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "There must be at leat one admin user." });
|
||||
}
|
||||
}
|
||||
|
||||
await user.update(req.body);
|
||||
|
||||
//todo, send socket IO to users channel.
|
||||
io.emit("user", {
|
||||
action: "update",
|
||||
user: user,
|
||||
});
|
||||
|
||||
res.status(200).json(user);
|
||||
return res.status(200).json(user);
|
||||
};
|
||||
|
||||
exports.delete = async (req, res) => {
|
||||
const io = getIO();
|
||||
const { userId } = req.params;
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
@@ -51,7 +164,18 @@ exports.delete = async (req, res) => {
|
||||
res.status(400).json({ error: "No user found with this id." });
|
||||
}
|
||||
|
||||
if (req.user.profile !== "admin") {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only administrators can edit users." });
|
||||
}
|
||||
|
||||
await user.destroy();
|
||||
|
||||
res.status(200).json({ message: "User deleted" });
|
||||
io.emit("user", {
|
||||
action: "delete",
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "User deleted" });
|
||||
};
|
||||
|
||||
141
backend/src/controllers/WhatsAppController.js
Normal file
141
backend/src/controllers/WhatsAppController.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const Yup = require("yup");
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
const { getIO } = require("../libs/socket");
|
||||
const { getWbot, initWbot, removeWbot } = require("../libs/wbot");
|
||||
const wbotMessageListener = require("../services/wbotMessageListener");
|
||||
const wbotMonitor = require("../services/wbotMonitor");
|
||||
|
||||
exports.index = async (req, res) => {
|
||||
const whatsapps = await Whatsapp.findAll();
|
||||
|
||||
return res.status(200).json(whatsapps);
|
||||
};
|
||||
|
||||
exports.store = async (req, res) => {
|
||||
const schema = Yup.object().shape({
|
||||
name: Yup.string().required().min(2),
|
||||
default: Yup.boolean()
|
||||
.required()
|
||||
.test(
|
||||
"Check-default",
|
||||
"Only one default whatsapp is permited",
|
||||
async value => {
|
||||
if (value === true) {
|
||||
const whatsappFound = await Whatsapp.findOne({
|
||||
where: { default: true },
|
||||
});
|
||||
return !Boolean(whatsappFound);
|
||||
} else return true;
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
await schema.validate(req.body);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const io = getIO();
|
||||
|
||||
const whatsapp = await Whatsapp.create(req.body);
|
||||
|
||||
if (!whatsapp) {
|
||||
return res.status(400).json({ error: "Cannot create whatsapp session." });
|
||||
}
|
||||
|
||||
initWbot(whatsapp)
|
||||
.then(() => {
|
||||
wbotMessageListener(whatsapp);
|
||||
wbotMonitor(whatsapp);
|
||||
})
|
||||
.catch(err => console.log(err));
|
||||
|
||||
io.emit("whatsapp", {
|
||||
action: "update",
|
||||
whatsapp: whatsapp,
|
||||
});
|
||||
|
||||
return res.status(200).json(whatsapp);
|
||||
};
|
||||
|
||||
exports.show = async (req, res) => {
|
||||
const { whatsappId } = req.params;
|
||||
const whatsapp = await Whatsapp.findByPk(whatsappId);
|
||||
|
||||
if (!whatsapp) {
|
||||
return res.status(200).json({ message: "Session not found" });
|
||||
}
|
||||
|
||||
return res.status(200).json(whatsapp);
|
||||
};
|
||||
|
||||
exports.update = async (req, res) => {
|
||||
const { whatsappId } = req.params;
|
||||
|
||||
const schema = Yup.object().shape({
|
||||
name: Yup.string().required().min(2),
|
||||
default: Yup.boolean()
|
||||
.required()
|
||||
.test(
|
||||
"Check-default",
|
||||
"Only one default whatsapp is permited",
|
||||
async value => {
|
||||
if (value === true) {
|
||||
const whatsappFound = await Whatsapp.findOne({
|
||||
where: { default: true },
|
||||
});
|
||||
if (whatsappFound) {
|
||||
return !(whatsappFound.id !== +whatsappId);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else return true;
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
await schema.validate(req.body);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const io = getIO();
|
||||
|
||||
const whatsapp = await Whatsapp.findByPk(whatsappId);
|
||||
|
||||
if (!whatsapp) {
|
||||
return res.status(404).json({ message: "Whatsapp not found" });
|
||||
}
|
||||
|
||||
await whatsapp.update(req.body);
|
||||
|
||||
io.emit("whatsapp", {
|
||||
action: "update",
|
||||
whatsapp: whatsapp,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Whatsapp updated" });
|
||||
};
|
||||
|
||||
exports.delete = async (req, res) => {
|
||||
const io = getIO();
|
||||
const { whatsappId } = req.params;
|
||||
|
||||
const whatsapp = await Whatsapp.findByPk(whatsappId);
|
||||
|
||||
if (!whatsapp) {
|
||||
return res.status(404).json({ message: "Whatsapp not found" });
|
||||
}
|
||||
|
||||
await whatsapp.destroy();
|
||||
removeWbot(whatsapp.id);
|
||||
|
||||
io.emit("whatsapp", {
|
||||
action: "delete",
|
||||
whatsappId: whatsapp.id,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Whatsapp deleted." });
|
||||
};
|
||||
@@ -1,45 +1,32 @@
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
const { getIO } = require("../libs/socket");
|
||||
const { getWbot } = require("../libs/wbot");
|
||||
// const Whatsapp = require("../models/Whatsapp");
|
||||
// const { getIO } = require("../libs/socket");
|
||||
// const { getWbot, initWbot, removeWbot } = require("../libs/wbot");
|
||||
// const wbotMessageListener = require("../services/wbotMessageListener");
|
||||
// const wbotMonitor = require("../services/wbotMonitor");
|
||||
|
||||
exports.show = async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const dbSession = await Whatsapp.findByPk(sessionId);
|
||||
// exports.show = async (req, res) => {
|
||||
// const { whatsappId } = req.params;
|
||||
// const dbSession = await Whatsapp.findByPk(whatsappId);
|
||||
|
||||
if (!dbSession) {
|
||||
return res.status(200).json({ message: "Session not found" });
|
||||
}
|
||||
// if (!dbSession) {
|
||||
// return res.status(200).json({ message: "Session not found" });
|
||||
// }
|
||||
|
||||
return res.status(200).json(dbSession);
|
||||
};
|
||||
|
||||
exports.delete = async (req, res) => {
|
||||
const wbot = getWbot();
|
||||
const io = getIO();
|
||||
|
||||
const { sessionId } = req.params;
|
||||
const dbSession = await Whatsapp.findByPk(sessionId);
|
||||
|
||||
if (!dbSession) {
|
||||
return res.status(200).json({ message: "Session not found" });
|
||||
}
|
||||
|
||||
await dbSession.update({ session: "", status: "pending" });
|
||||
wbot.logout();
|
||||
|
||||
io.emit("session", {
|
||||
action: "logout",
|
||||
session: dbSession,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "session disconnected" });
|
||||
};
|
||||
|
||||
// exports.getContacts = async (req, res, next) => {
|
||||
// const io = getIO();
|
||||
// const wbot = getWbot();
|
||||
|
||||
// const phoneContacts = await wbot.getContacts();
|
||||
|
||||
// return res.status(200).json(phoneContacts);
|
||||
// return res.status(200).json(dbSession);
|
||||
// };
|
||||
|
||||
// exports.delete = async (req, res) => {
|
||||
// const { whatsappId } = req.params;
|
||||
|
||||
// const dbSession = await Whatsapp.findByPk(whatsappId);
|
||||
|
||||
// if (!dbSession) {
|
||||
// return res.status(404).json({ message: "Session not found" });
|
||||
// }
|
||||
|
||||
// const wbot = getWbot(dbSession.id);
|
||||
|
||||
// wbot.logout();
|
||||
|
||||
// return res.status(200).json({ message: "Session disconnected." });
|
||||
// };
|
||||
|
||||
@@ -7,8 +7,17 @@ const Ticket = require("../models/Ticket");
|
||||
const Message = require("../models/Message");
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
const ContactCustomField = require("../models/ContactCustomField");
|
||||
const Setting = require("../models/Setting");
|
||||
|
||||
const models = [User, Contact, Ticket, Message, Whatsapp, ContactCustomField];
|
||||
const models = [
|
||||
User,
|
||||
Contact,
|
||||
Ticket,
|
||||
Message,
|
||||
Whatsapp,
|
||||
ContactCustomField,
|
||||
Setting,
|
||||
];
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn("Users", "profile", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: "admin",
|
||||
});
|
||||
},
|
||||
|
||||
down: queryInterface => {
|
||||
return queryInterface.removeColumn("Users", "profile");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.createTable("Settings", {
|
||||
key: {
|
||||
type: Sequelize.STRING,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
down: queryInterface => {
|
||||
return queryInterface.dropTable("Settings");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn("Whatsapps", "name", {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: queryInterface => {
|
||||
return queryInterface.removeColumn("Whatsapps", "name");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn("Whatsapps", "default", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
});
|
||||
},
|
||||
|
||||
down: queryInterface => {
|
||||
return queryInterface.removeColumn("Whatsapps", "default");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn("Tickets", "whatsappId", {
|
||||
type: Sequelize.INTEGER,
|
||||
references: { model: "Whatsapps", key: "id" },
|
||||
onUpdate: "CASCADE",
|
||||
onDelete: "SET NULL",
|
||||
});
|
||||
},
|
||||
|
||||
down: queryInterface => {
|
||||
return queryInterface.removeColumn("Tickets", "whatsappId");
|
||||
},
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.bulkInsert(
|
||||
"Contacts",
|
||||
[
|
||||
{
|
||||
name: "Joana Doe",
|
||||
profilePicUrl:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1834&q=80",
|
||||
number: 5512345678,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
name: "John Rulles",
|
||||
profilePicUrl:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=634&q=80",
|
||||
number: 5512345679,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
name: "Jonas Jhones",
|
||||
profilePicUrl:
|
||||
"https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=634&q=80",
|
||||
number: 5512345680,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
name: "Julie June",
|
||||
profilePicUrl:
|
||||
"https://images.unsplash.com/photo-1493666438817-866a91353ca9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1049&q=80",
|
||||
number: 5512345681,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
{}
|
||||
);
|
||||
},
|
||||
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.bulkDelete("Contacts", null, {});
|
||||
},
|
||||
};
|
||||
@@ -1,261 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.bulkInsert(
|
||||
"Tickets",
|
||||
[
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 4,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
status: "pending",
|
||||
lastMessage: "hello!",
|
||||
contactId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
{}
|
||||
);
|
||||
},
|
||||
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.bulkDelete("Tickets", null, {});
|
||||
},
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.bulkInsert(
|
||||
"Messages",
|
||||
[
|
||||
{
|
||||
id: "12312321342",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 0,
|
||||
ticketId: 1,
|
||||
fromMe: false,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321313",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 3,
|
||||
ticketId: 1,
|
||||
fromMe: true,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321314",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 3,
|
||||
ticketId: 1,
|
||||
fromMe: true,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321315",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 0,
|
||||
ticketId: 1,
|
||||
fromMe: false,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321316",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 0,
|
||||
ticketId: 5,
|
||||
fromMe: false,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321355",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 3,
|
||||
ticketId: 5,
|
||||
fromMe: true,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321318",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 3,
|
||||
ticketId: 5,
|
||||
fromMe: true,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321319",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 0,
|
||||
ticketId: 5,
|
||||
fromMe: false,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321399",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 0,
|
||||
ticketId: 11,
|
||||
fromMe: false,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321391",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 3,
|
||||
ticketId: 11,
|
||||
fromMe: true,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321392",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 3,
|
||||
ticketId: 11,
|
||||
fromMe: true,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "12312321393",
|
||||
body:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
ack: 0,
|
||||
ticketId: 11,
|
||||
fromMe: false,
|
||||
read: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
{}
|
||||
);
|
||||
},
|
||||
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.bulkDelete("Messages", null, {});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: queryInterface => {
|
||||
return queryInterface.bulkInsert(
|
||||
"Settings",
|
||||
[
|
||||
{
|
||||
key: "userCreation",
|
||||
value: "enabled",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
{}
|
||||
);
|
||||
},
|
||||
|
||||
down: queryInterface => {
|
||||
return queryInterface.bulkDelete("Settings", null, {});
|
||||
},
|
||||
};
|
||||
@@ -3,78 +3,108 @@ const { Client } = require("whatsapp-web.js");
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
const { getIO } = require("../libs/socket");
|
||||
|
||||
let wbot;
|
||||
let sessions = [];
|
||||
|
||||
module.exports = {
|
||||
init: async () => {
|
||||
let sessionCfg;
|
||||
initWbot: async whatsapp => {
|
||||
try {
|
||||
const io = getIO();
|
||||
const sessionName = whatsapp.name;
|
||||
let sessionCfg;
|
||||
|
||||
const [dbSession] = await Whatsapp.findOrCreate({
|
||||
where: { id: 1 },
|
||||
defaults: {
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
if (dbSession && dbSession.session) {
|
||||
sessionCfg = JSON.parse(dbSession.session);
|
||||
if (whatsapp && whatsapp.session) {
|
||||
sessionCfg = JSON.parse(whatsapp.session);
|
||||
}
|
||||
|
||||
const sessionIndex = sessions.findIndex(s => s.id === whatsapp.id);
|
||||
if (sessionIndex !== -1) {
|
||||
sessions[sessionIndex].destroy();
|
||||
sessions.splice(sessionIndex, 1);
|
||||
}
|
||||
|
||||
const wbot = new Client({
|
||||
session: sessionCfg,
|
||||
restartOnAuthFail: true,
|
||||
});
|
||||
wbot.initialize();
|
||||
|
||||
wbot.on("qr", async qr => {
|
||||
console.log("Session:", sessionName);
|
||||
|
||||
qrCode.generate(qr, { small: true });
|
||||
|
||||
await whatsapp.update({ qrcode: qr, status: "qrcode" });
|
||||
|
||||
io.emit("whatsappSession", {
|
||||
action: "update",
|
||||
session: whatsapp,
|
||||
});
|
||||
});
|
||||
|
||||
wbot.on("authenticated", async session => {
|
||||
console.log("Session:", sessionName, "AUTHENTICATED");
|
||||
|
||||
await whatsapp.update({
|
||||
session: JSON.stringify(session),
|
||||
status: "authenticated",
|
||||
});
|
||||
|
||||
io.emit("whatsappSession", {
|
||||
action: "update",
|
||||
session: whatsapp,
|
||||
});
|
||||
});
|
||||
|
||||
wbot.on("auth_failure", async msg => {
|
||||
console.error("Session:", sessionName, "AUTHENTICATION FAILURE", msg);
|
||||
|
||||
await whatsapp.update({ session: "" });
|
||||
});
|
||||
|
||||
wbot.on("ready", async () => {
|
||||
console.log("Session:", sessionName, "READY");
|
||||
|
||||
await whatsapp.update({
|
||||
status: "CONNECTED",
|
||||
qrcode: "",
|
||||
});
|
||||
|
||||
io.emit("whatsappSession", {
|
||||
action: "update",
|
||||
session: whatsapp,
|
||||
});
|
||||
|
||||
wbot.sendPresenceAvailable();
|
||||
});
|
||||
|
||||
wbot.id = whatsapp.id;
|
||||
sessions.push(wbot);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
wbot = new Client({
|
||||
session: sessionCfg,
|
||||
restartOnAuthFail: true,
|
||||
});
|
||||
wbot.initialize();
|
||||
wbot.on("qr", async qr => {
|
||||
qrCode.generate(qr, { small: true });
|
||||
await dbSession.update({ id: 1, qrcode: qr, status: "disconnected" });
|
||||
getIO().emit("session", {
|
||||
action: "update",
|
||||
qr: qr,
|
||||
session: dbSession,
|
||||
});
|
||||
});
|
||||
wbot.on("authenticated", async session => {
|
||||
console.log("AUTHENTICATED");
|
||||
await dbSession.update({
|
||||
id: 1,
|
||||
session: JSON.stringify(session),
|
||||
status: "authenticated",
|
||||
});
|
||||
getIO().emit("session", {
|
||||
action: "authentication",
|
||||
session: dbSession,
|
||||
});
|
||||
});
|
||||
wbot.on("auth_failure", async msg => {
|
||||
console.error("AUTHENTICATION FAILURE", msg);
|
||||
await Whatsapp.update({ session: "" }, { where: { id: 1 } });
|
||||
});
|
||||
wbot.on("ready", async () => {
|
||||
console.log("READY");
|
||||
await dbSession.update(
|
||||
{ status: "CONNECTED", qrcode: "" },
|
||||
{ where: { id: 1 } }
|
||||
);
|
||||
// const chats = await wbot.getChats(); // pega as mensagens nao lidas (recebidas quando o bot estava offline)
|
||||
// let unreadMessages; // todo > salvar isso no DB pra mostrar no frontend
|
||||
// for (let chat of chats) {
|
||||
// if (chat.unreadCount > 0) {
|
||||
// unreadMessages = await chat.fetchMessages({
|
||||
// limit: chat.unreadCount,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// console.log(unreadMessages);
|
||||
wbot.sendPresenceAvailable();
|
||||
});
|
||||
return { wbot, dbSession };
|
||||
return null;
|
||||
},
|
||||
|
||||
getWbot: () => {
|
||||
if (!wbot) {
|
||||
throw new Error("Wbot not initialized");
|
||||
getWbot: whatsappId => {
|
||||
const sessionIndex = sessions.findIndex(s => s.id === whatsappId);
|
||||
|
||||
if (sessionIndex === -1) {
|
||||
console.log("This Wbot session is not initialized");
|
||||
return null;
|
||||
}
|
||||
return sessions[sessionIndex];
|
||||
},
|
||||
|
||||
removeWbot: whatsappId => {
|
||||
try {
|
||||
const sessionIndex = sessions.findIndex(s => s.id === whatsappId);
|
||||
if (sessionIndex !== -1) {
|
||||
sessions[sessionIndex].destroy();
|
||||
sessions.splice(sessionIndex, 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
return wbot;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const util = require("util");
|
||||
|
||||
const User = require("../models/User");
|
||||
const authConfig = require("../config/auth");
|
||||
|
||||
module.exports = async (req, res, next) => {
|
||||
@@ -11,12 +13,24 @@ module.exports = async (req, res, next) => {
|
||||
|
||||
const [, token] = authHeader.split(" ");
|
||||
|
||||
jwt.verify(token, authConfig.secret, (error, result) => {
|
||||
if (error) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
try {
|
||||
const decoded = await util.promisify(jwt.verify)(token, authConfig.secret);
|
||||
|
||||
const user = await User.findByPk(decoded.userId, {
|
||||
attributes: ["id", "name", "profile", "email"],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "The token corresponding user does not exists." });
|
||||
}
|
||||
req.userId = result.userId;
|
||||
// todo >> find user in DB and store in req.user to use latter, or throw an error if user not exists anymore
|
||||
next();
|
||||
});
|
||||
|
||||
req.user = user;
|
||||
|
||||
return next();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return res.status(401).json({ error: "Invalid Token" });
|
||||
}
|
||||
};
|
||||
|
||||
24
backend/src/models/Setting.js
Normal file
24
backend/src/models/Setting.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const Sequelize = require("sequelize");
|
||||
|
||||
class Setting extends Sequelize.Model {
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
key: {
|
||||
type: Sequelize.STRING,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
value: { type: Sequelize.TEXT, allowNull: false },
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
}
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Setting;
|
||||
@@ -39,6 +39,10 @@ class Ticket extends Sequelize.Model {
|
||||
static associate(models) {
|
||||
this.belongsTo(models.Contact, { foreignKey: "contactId", as: "contact" });
|
||||
this.belongsTo(models.User, { foreignKey: "userId", as: "user" });
|
||||
this.belongsTo(models.Whatsapp, {
|
||||
foreignKey: "whatsappId",
|
||||
as: "whatsapp",
|
||||
});
|
||||
this.hasMany(models.Message, { foreignKey: "ticketId", as: "messages" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class User extends Sequelize.Model {
|
||||
{
|
||||
name: { type: Sequelize.STRING },
|
||||
password: { type: Sequelize.VIRTUAL },
|
||||
profile: { type: Sequelize.STRING, defaultValue: "admin" },
|
||||
passwordHash: { type: Sequelize.STRING },
|
||||
email: { type: Sequelize.STRING },
|
||||
},
|
||||
|
||||
@@ -6,9 +6,15 @@ class Whatsapp extends Sequelize.Model {
|
||||
{
|
||||
session: { type: Sequelize.TEXT },
|
||||
qrcode: { type: Sequelize.TEXT },
|
||||
name: { type: Sequelize.STRING, unique: true, allowNull: false },
|
||||
status: { type: Sequelize.STRING },
|
||||
battery: { type: Sequelize.STRING },
|
||||
plugged: { type: Sequelize.BOOLEAN },
|
||||
default: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
@@ -17,6 +23,10 @@ class Whatsapp extends Sequelize.Model {
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
static associate(models) {
|
||||
this.hasMany(models.Ticket, { foreignKey: "whatsappId", as: "tickets" });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Whatsapp;
|
||||
|
||||
21
backend/src/router/index.js
Normal file
21
backend/src/router/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const express = require("express");
|
||||
|
||||
const AuthRoutes = require("./routes/auth");
|
||||
const TicketsRoutes = require("./routes/tickets");
|
||||
const MessagesRoutes = require("./routes/messages");
|
||||
const ContactsRoutes = require("./routes/contacts");
|
||||
const WhatsRoutes = require("./routes/whatsapp");
|
||||
const UsersRoutes = require("./routes/users");
|
||||
const SettingsRoutes = require("./routes/settings");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.use("/auth", AuthRoutes);
|
||||
routes.use(TicketsRoutes);
|
||||
routes.use(MessagesRoutes);
|
||||
routes.use(ContactsRoutes);
|
||||
routes.use(WhatsRoutes);
|
||||
routes.use(UsersRoutes);
|
||||
routes.use(SettingsRoutes);
|
||||
|
||||
module.exports = routes;
|
||||
16
backend/src/router/routes/auth.js
Normal file
16
backend/src/router/routes/auth.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const express = require("express");
|
||||
const SessionController = require("../../controllers/SessionController");
|
||||
const UserController = require("../../controllers/UserController");
|
||||
const isAuth = require("../../middleware/is-auth");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.post("/signup", UserController.store);
|
||||
|
||||
routes.post("/login", SessionController.store);
|
||||
|
||||
routes.get("/check", isAuth, (req, res) => {
|
||||
res.status(200).json({ authenticated: true });
|
||||
});
|
||||
|
||||
module.exports = routes;
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require("express");
|
||||
const isAuth = require("../middleware/is-auth");
|
||||
const isAuth = require("../../middleware/is-auth");
|
||||
|
||||
const ContactController = require("../controllers/ContactController");
|
||||
const ImportPhoneContactsController = require("../controllers/ImportPhoneContactsController");
|
||||
const ContactController = require("../../controllers/ContactController");
|
||||
const ImportPhoneContactsController = require("../../controllers/ImportPhoneContactsController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require("express");
|
||||
const isAuth = require("../middleware/is-auth");
|
||||
const isAuth = require("../../middleware/is-auth");
|
||||
|
||||
const MessageController = require("../controllers/MessageController");
|
||||
const MessageController = require("../../controllers/MessageController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
14
backend/src/router/routes/settings.js
Normal file
14
backend/src/router/routes/settings.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const express = require("express");
|
||||
const isAuth = require("../../middleware/is-auth");
|
||||
|
||||
const SettingController = require("../../controllers/SettingController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.get("/settings", isAuth, SettingController.index);
|
||||
|
||||
// routes.get("/settings/:settingKey", isAuth, SettingsController.show);
|
||||
|
||||
routes.put("/settings/:settingKey", isAuth, SettingController.update);
|
||||
|
||||
module.exports = routes;
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require("express");
|
||||
const isAuth = require("../middleware/is-auth");
|
||||
const isAuth = require("../../middleware/is-auth");
|
||||
|
||||
const TicketController = require("../controllers/TicketController");
|
||||
const TicketController = require("../../controllers/TicketController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
18
backend/src/router/routes/users.js
Normal file
18
backend/src/router/routes/users.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const express = require("express");
|
||||
|
||||
const isAuth = require("../../middleware/is-auth");
|
||||
const UserController = require("../../controllers/UserController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.get("/users", isAuth, UserController.index);
|
||||
|
||||
routes.post("/users", isAuth, UserController.store);
|
||||
|
||||
routes.put("/users/:userId", isAuth, UserController.update);
|
||||
|
||||
routes.get("/users/:userId", isAuth, UserController.show);
|
||||
|
||||
routes.delete("/users/:userId", isAuth, UserController.delete);
|
||||
|
||||
module.exports = routes;
|
||||
18
backend/src/router/routes/whatsapp.js
Normal file
18
backend/src/router/routes/whatsapp.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const express = require("express");
|
||||
const isAuth = require("../../middleware/is-auth");
|
||||
|
||||
const WhatsAppController = require("../../controllers/WhatsAppController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.get("/whatsapp/", isAuth, WhatsAppController.index);
|
||||
|
||||
routes.post("/whatsapp/", isAuth, WhatsAppController.store);
|
||||
|
||||
routes.get("/whatsapp/:whatsappId", isAuth, WhatsAppController.show);
|
||||
|
||||
routes.put("/whatsapp/:whatsappId", isAuth, WhatsAppController.update);
|
||||
|
||||
routes.delete("/whatsapp/:whatsappId", isAuth, WhatsAppController.delete);
|
||||
|
||||
module.exports = routes;
|
||||
20
backend/src/router/routes/whatsappsessions.js
Normal file
20
backend/src/router/routes/whatsappsessions.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const express = require("express");
|
||||
const isAuth = require("../../middleware/is-auth");
|
||||
|
||||
const WhatsAppSessionController = require("../../controllers/WhatsAppSessionController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.get(
|
||||
"/whatsappsession/:whatsappId",
|
||||
isAuth,
|
||||
WhatsAppSessionController.show
|
||||
);
|
||||
|
||||
routes.delete(
|
||||
"/whatsappsession/:whatsappId",
|
||||
isAuth,
|
||||
WhatsAppSessionController.delete
|
||||
);
|
||||
|
||||
module.exports = routes;
|
||||
@@ -1,13 +0,0 @@
|
||||
const express = require("express");
|
||||
const SessionController = require("../controllers/SessionController");
|
||||
const isAuth = require("../middleware/is-auth");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.post("/login", SessionController.store);
|
||||
|
||||
routes.get("/check", isAuth, (req, res) => {
|
||||
res.status(200).json({ authenticated: true });
|
||||
});
|
||||
|
||||
module.exports = routes;
|
||||
@@ -1,36 +0,0 @@
|
||||
const express = require("express");
|
||||
const { body } = require("express-validator");
|
||||
const User = require("../models/User");
|
||||
|
||||
const isAuth = require("../middleware/is-auth");
|
||||
const UserController = require("../controllers/UserController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.get("/users", isAuth, UserController.index);
|
||||
|
||||
routes.post(
|
||||
"/users",
|
||||
[
|
||||
body("email")
|
||||
.isEmail()
|
||||
.withMessage("Email inválido")
|
||||
.custom((value, { req }) => {
|
||||
return User.findOne({ where: { email: value } }).then(user => {
|
||||
if (user) {
|
||||
return Promise.reject("An user with this email already exists!");
|
||||
}
|
||||
});
|
||||
})
|
||||
.normalizeEmail(),
|
||||
body("password").trim().isLength({ min: 5 }),
|
||||
body("name").trim().not().isEmpty(),
|
||||
],
|
||||
UserController.store
|
||||
);
|
||||
|
||||
routes.put("/users/:userId", isAuth, UserController.update);
|
||||
|
||||
routes.delete("/users/:userId", isAuth, UserController.delete);
|
||||
|
||||
module.exports = routes;
|
||||
@@ -1,23 +0,0 @@
|
||||
const express = require("express");
|
||||
const isAuth = require("../middleware/is-auth");
|
||||
|
||||
const WhatsAppSessionController = require("../controllers/WhatsAppSessionController");
|
||||
|
||||
const routes = express.Router();
|
||||
|
||||
routes.get(
|
||||
"/whatsapp/session/:sessionId",
|
||||
isAuth,
|
||||
WhatsAppSessionController.show
|
||||
);
|
||||
|
||||
routes.delete(
|
||||
"/whatsapp/session/:sessionId",
|
||||
isAuth,
|
||||
WhatsAppSessionController.delete
|
||||
);
|
||||
|
||||
// fetch contacts in user cellphone, not in use
|
||||
// routes.get("/whatsapp/contacts", isAuth, WhatsappController.getContacts);
|
||||
|
||||
module.exports = routes;
|
||||
@@ -7,9 +7,10 @@ const Sentry = require("@sentry/node");
|
||||
const Contact = require("../models/Contact");
|
||||
const Ticket = require("../models/Ticket");
|
||||
const Message = require("../models/Message");
|
||||
const Whatsapp = require("../models/Whatsapp");
|
||||
|
||||
const { getIO } = require("../libs/socket");
|
||||
const { getWbot, init } = require("../libs/wbot");
|
||||
const { getWbot, initWbot } = require("../libs/wbot");
|
||||
|
||||
const verifyContact = async (msgContact, profilePicUrl) => {
|
||||
let contact = await Contact.findOne({
|
||||
@@ -29,7 +30,7 @@ const verifyContact = async (msgContact, profilePicUrl) => {
|
||||
return contact;
|
||||
};
|
||||
|
||||
const verifyTicket = async contact => {
|
||||
const verifyTicket = async (contact, whatsappId) => {
|
||||
let ticket = await Ticket.findOne({
|
||||
where: {
|
||||
status: {
|
||||
@@ -57,6 +58,7 @@ const verifyTicket = async contact => {
|
||||
ticket = await Ticket.create({
|
||||
contactId: contact.id,
|
||||
status: "pending",
|
||||
whatsappId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,12 +134,14 @@ const handleMessage = async (msg, ticket, contact) => {
|
||||
});
|
||||
};
|
||||
|
||||
const wbotMessageListener = () => {
|
||||
const wbot = getWbot();
|
||||
const wbotMessageListener = whatsapp => {
|
||||
const whatsappId = whatsapp.id;
|
||||
const wbot = getWbot(whatsappId);
|
||||
const io = getIO();
|
||||
|
||||
wbot.on("message_create", async msg => {
|
||||
// console.log(msg);
|
||||
|
||||
if (
|
||||
msg.from === "status@broadcast" ||
|
||||
msg.type === "location" ||
|
||||
@@ -158,7 +162,7 @@ const wbotMessageListener = () => {
|
||||
|
||||
const profilePicUrl = await msgContact.getProfilePicUrl();
|
||||
const contact = await verifyContact(msgContact, profilePicUrl);
|
||||
const ticket = await verifyTicket(contact);
|
||||
const ticket = await verifyTicket(contact, whatsappId);
|
||||
|
||||
//return if message was already created by messageController
|
||||
if (msg.fromMe) {
|
||||
|
||||
@@ -3,17 +3,18 @@ const Sentry = require("@sentry/node");
|
||||
const wbotMessageListener = require("./wbotMessageListener");
|
||||
|
||||
const { getIO } = require("../libs/socket");
|
||||
const { getWbot, init } = require("../libs/wbot");
|
||||
const { getWbot, initWbot } = require("../libs/wbot");
|
||||
|
||||
const wbotMonitor = dbSession => {
|
||||
const wbotMonitor = whatsapp => {
|
||||
const io = getIO();
|
||||
const wbot = getWbot();
|
||||
const sessionName = whatsapp.name;
|
||||
const wbot = getWbot(whatsapp.id);
|
||||
|
||||
try {
|
||||
wbot.on("change_state", async newState => {
|
||||
console.log("monitor", newState);
|
||||
console.log("Monitor session:", sessionName, newState);
|
||||
try {
|
||||
await dbSession.update({ status: newState });
|
||||
await whatsapp.update({ status: newState });
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
console.log(err);
|
||||
@@ -21,16 +22,18 @@ const wbotMonitor = dbSession => {
|
||||
|
||||
io.emit("session", {
|
||||
action: "update",
|
||||
session: dbSession,
|
||||
session: whatsapp,
|
||||
});
|
||||
});
|
||||
|
||||
wbot.on("change_battery", async batteryInfo => {
|
||||
const { battery, plugged } = batteryInfo;
|
||||
console.log(`Battery: ${battery}% - Charging? ${plugged}`);
|
||||
console.log(
|
||||
`Battery session: ${sessionName} ${battery}% - Charging? ${plugged}`
|
||||
);
|
||||
|
||||
try {
|
||||
await dbSession.update({ battery, plugged });
|
||||
await whatsapp.update({ battery, plugged });
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
console.log(err);
|
||||
@@ -38,30 +41,30 @@ const wbotMonitor = dbSession => {
|
||||
|
||||
io.emit("session", {
|
||||
action: "update",
|
||||
session: dbSession,
|
||||
session: whatsapp,
|
||||
});
|
||||
});
|
||||
|
||||
wbot.on("disconnected", async reason => {
|
||||
console.log("disconnected", reason);
|
||||
console.log("Disconnected session:", sessionName, reason);
|
||||
try {
|
||||
await dbSession.update({ status: "disconnected" });
|
||||
await whatsapp.update({ status: "disconnected" });
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
io.emit("session", {
|
||||
action: "logout",
|
||||
session: dbSession,
|
||||
action: "update",
|
||||
session: whatsapp,
|
||||
});
|
||||
|
||||
setTimeout(
|
||||
() =>
|
||||
init()
|
||||
.then(({ dbSession }) => {
|
||||
wbotMessageListener();
|
||||
wbotMonitor(dbSession);
|
||||
initWbot(whatsapp)
|
||||
.then(() => {
|
||||
wbotMessageListener(whatsapp);
|
||||
wbotMonitor(whatsapp);
|
||||
})
|
||||
.catch(err => {
|
||||
Sentry.captureException(err);
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@babel/runtime@^7.10.5":
|
||||
version "7.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
|
||||
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@pedroslopez/moduleraid@^4.1.0":
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@pedroslopez/moduleraid/-/moduleraid-4.1.0.tgz#468f7195fddc9f367e672ace9269f0698cf4c404"
|
||||
@@ -854,14 +861,6 @@ express-async-errors@^3.1.1:
|
||||
resolved "https://registry.yarnpkg.com/express-async-errors/-/express-async-errors-3.1.1.tgz#6053236d61d21ddef4892d6bd1d736889fc9da41"
|
||||
integrity sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==
|
||||
|
||||
express-validator@^6.5.0:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-6.6.1.tgz#c53046f615d27fcb78be786e018dcd60bd9c6c5c"
|
||||
integrity sha512-+MrZKJ3eGYXkNF9p9Zf7MS7NkPJFg9MDYATU5c80Cf4F62JdLBIjWxy6481tRC0y1NnC9cgOw8FuN364bWaGhA==
|
||||
dependencies:
|
||||
lodash "^4.17.19"
|
||||
validator "^13.1.1"
|
||||
|
||||
express@^4.17.1:
|
||||
version "4.17.1"
|
||||
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
|
||||
@@ -950,6 +949,11 @@ find-up@^3.0.0:
|
||||
dependencies:
|
||||
locate-path "^3.0.0"
|
||||
|
||||
fn-name@~3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-3.0.0.tgz#0596707f635929634d791f452309ab41558e3c5c"
|
||||
integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA==
|
||||
|
||||
forwarded@~0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
|
||||
@@ -1382,6 +1386,11 @@ locate-path@^3.0.0:
|
||||
p-locate "^3.0.0"
|
||||
path-exists "^3.0.0"
|
||||
|
||||
lodash-es@^4.17.11:
|
||||
version "4.17.15"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
|
||||
integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==
|
||||
|
||||
lodash.includes@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||
@@ -1417,7 +1426,7 @@ lodash.once@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
|
||||
integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
|
||||
|
||||
lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.5:
|
||||
lodash@^4.17.15, lodash@^4.17.5:
|
||||
version "4.17.20"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
|
||||
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
|
||||
@@ -1801,6 +1810,11 @@ progress@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
|
||||
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
|
||||
|
||||
property-expr@^2.0.2:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.4.tgz#37b925478e58965031bb612ec5b3260f8241e910"
|
||||
integrity sha512-sFPkHQjVKheDNnPvotjQmm3KD3uk1fWKUN7CrpdbwmUx3CrG3QiM8QpTSimvig5vTXmTvjz7+TDvXOI9+4rkcg==
|
||||
|
||||
proto-list@~1.2.1:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
|
||||
@@ -1941,6 +1955,11 @@ redeyed@~2.1.0:
|
||||
dependencies:
|
||||
esprima "~4.0.0"
|
||||
|
||||
regenerator-runtime@^0.13.4:
|
||||
version "0.13.7"
|
||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
|
||||
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
|
||||
|
||||
registry-auth-token@^4.0.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.0.tgz#1d37dffda72bbecd0f581e4715540213a65eb7da"
|
||||
@@ -2266,6 +2285,11 @@ supports-color@^7.1.0:
|
||||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
synchronous-promise@^2.0.13:
|
||||
version "2.0.13"
|
||||
resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.13.tgz#9d8c165ddee69c5a6542862b405bc50095926702"
|
||||
integrity sha512-R9N6uDkVsghHePKh1TEqbnLddO2IY25OcsksyFp/qBe7XYd0PVbKEWxhcdMhpLzE1I6skj5l4aEZ3CRxcbArlA==
|
||||
|
||||
tar-fs@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.0.tgz#d1cdd121ab465ee0eb9ccde2d35049d3f3daf0d5"
|
||||
@@ -2332,6 +2356,11 @@ toposort-class@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/toposort-class/-/toposort-class-1.0.1.tgz#7ffd1f78c8be28c3ba45cd4e1a3f5ee193bd9988"
|
||||
integrity sha1-f/0feMi+KMO6Rc1OGj9e4ZO9mYg=
|
||||
|
||||
toposort@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
|
||||
integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=
|
||||
|
||||
touch@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
||||
@@ -2464,11 +2493,6 @@ validator@^10.11.0:
|
||||
resolved "https://registry.yarnpkg.com/validator/-/validator-10.11.0.tgz#003108ea6e9a9874d31ccc9e5006856ccd76b228"
|
||||
integrity sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==
|
||||
|
||||
validator@^13.1.1:
|
||||
version "13.1.1"
|
||||
resolved "https://registry.yarnpkg.com/validator/-/validator-13.1.1.tgz#f8811368473d2173a9d8611572b58c5783f223bf"
|
||||
integrity sha512-8GfPiwzzRoWTg7OV1zva1KvrSemuMkv07MA9TTl91hfhe+wKrsrgVN4H2QSFd/U/FhiU3iWPYVgvbsOGwhyFWw==
|
||||
|
||||
vary@^1, vary@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
@@ -2614,3 +2638,16 @@ youch@^2.0.10:
|
||||
cookie "^0.3.1"
|
||||
mustache "^3.0.0"
|
||||
stack-trace "0.0.10"
|
||||
|
||||
yup@^0.29.3:
|
||||
version "0.29.3"
|
||||
resolved "https://registry.yarnpkg.com/yup/-/yup-0.29.3.tgz#69a30fd3f1c19f5d9e31b1cf1c2b851ce8045fea"
|
||||
integrity sha512-RNUGiZ/sQ37CkhzKFoedkeMfJM0vNQyaz+wRZJzxdKE7VfDeVKH8bb4rr7XhRLbHJz5hSjoDNwMEIaKhuMZ8gQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.5"
|
||||
fn-name "~3.0.0"
|
||||
lodash "^4.17.15"
|
||||
lodash-es "^4.17.11"
|
||||
property-expr "^2.0.2"
|
||||
synchronous-promise "^2.0.13"
|
||||
toposort "^2.0.2"
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"react-scripts": "3.4.1",
|
||||
"react-toastify": "^6.0.8",
|
||||
"recharts": "^1.8.5",
|
||||
"socket.io-client": "^2.3.0"
|
||||
"socket.io-client": "^2.3.0",
|
||||
"yup": "^0.29.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
||||
@@ -10,8 +10,18 @@ const App = () => {
|
||||
|
||||
const theme = createMuiTheme(
|
||||
{
|
||||
scrollbarStyles: {
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
boxShadow: "inset 0 0 6px rgba(0, 0, 0, 0.3)",
|
||||
backgroundColor: "#e8e8e8",
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
primary: { main: "#1976d2" },
|
||||
primary: { main: "#2576d2" },
|
||||
},
|
||||
},
|
||||
locale
|
||||
|
||||
@@ -48,14 +48,7 @@ const useStyles = makeStyles(theme => ({
|
||||
padding: "8px 0px 8px 8px",
|
||||
height: "100%",
|
||||
overflowY: "scroll",
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
boxShadow: "inset 0 0 6px rgba(0, 0, 0, 0.3)",
|
||||
backgroundColor: "#e8e8e8",
|
||||
},
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
|
||||
contactAvatar: {
|
||||
@@ -80,17 +73,6 @@ const useStyles = makeStyles(theme => ({
|
||||
padding: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
// overflowX: "scroll",
|
||||
// flex: 1,
|
||||
// "&::-webkit-scrollbar": {
|
||||
// width: "8px",
|
||||
// height: "8px",
|
||||
// },
|
||||
// "&::-webkit-scrollbar-thumb": {
|
||||
// // borderRadius: "2px",
|
||||
// boxShadow: "inset 0 0 6px rgba(0, 0, 0, 0.3)",
|
||||
// backgroundColor: "#e8e8e8",
|
||||
// },
|
||||
},
|
||||
contactExtraInfo: {
|
||||
marginTop: 4,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { Formik, FieldArray } from "formik";
|
||||
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";
|
||||
@@ -52,6 +54,15 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
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 }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
@@ -66,8 +77,15 @@ const ContactModal = ({ open, onClose, contactId }) => {
|
||||
useEffect(() => {
|
||||
const fetchContact = async () => {
|
||||
if (!contactId) return;
|
||||
const res = await api.get(`/contacts/${contactId}`);
|
||||
setContact(res.data);
|
||||
try {
|
||||
const { data } = await api.get(`/contacts/${contactId}`);
|
||||
setContact(data);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchContact();
|
||||
@@ -85,78 +103,70 @@ const ContactModal = ({ open, onClose, contactId }) => {
|
||||
} else {
|
||||
await api.post("/contacts", values);
|
||||
}
|
||||
toast.success("Contact saved sucessfully!");
|
||||
} catch (err) {
|
||||
alert(err.response.data.error);
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="lg"
|
||||
scroll="paper"
|
||||
className={classes.modal}
|
||||
>
|
||||
<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}
|
||||
onSubmit={(values, { setSubmitting }) => {
|
||||
validationSchema={ContactSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveContact(values);
|
||||
setSubmitting(false);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
}) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{contactId
|
||||
? `${i18n.t("contactModal.title.edit")}`
|
||||
: `${i18n.t("contactModal.title.add")}`}
|
||||
</DialogTitle>
|
||||
{({ values, errors, touched, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{i18n.t("contactModal.form.mainInfo")}
|
||||
</Typography>
|
||||
<TextField
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.name")}
|
||||
name="name"
|
||||
value={values.name || ""}
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
required
|
||||
className={classes.textField}
|
||||
/>
|
||||
<TextField
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.number")}
|
||||
name="number"
|
||||
value={values.number || ""}
|
||||
onChange={handleChange}
|
||||
error={touched.number && Boolean(errors.number)}
|
||||
helperText={touched.number && errors.number}
|
||||
placeholder="5513912344321"
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<TextField
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.email")}
|
||||
name="email"
|
||||
value={values.email || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Endereço de Email"
|
||||
error={touched.email && Boolean(errors.email)}
|
||||
helperText={touched.email && errors.email}
|
||||
placeholder="Email address"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
variant="outlined"
|
||||
@@ -179,25 +189,21 @@ const ContactModal = ({ open, onClose, contactId }) => {
|
||||
className={classes.extraAttr}
|
||||
key={`${index}-info`}
|
||||
>
|
||||
<TextField
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.extraName")}
|
||||
name={`extraInfo[${index}].name`}
|
||||
value={info.name || ""}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
required
|
||||
className={classes.textField}
|
||||
/>
|
||||
<TextField
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.extraValue")}
|
||||
name={`extraInfo[${index}].value`}
|
||||
value={info.value || ""}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
required
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -248,7 +254,7 @@ const ContactModal = ({ open, onClose, contactId }) => {
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import React from "react";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import Skeleton from "@material-ui/lab/Skeleton";
|
||||
|
||||
const ContactsSekeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={90} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={55} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={60} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={100} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={90} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={55} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={60} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={100} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={90} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={55} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={60} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={100} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={90} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={55} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={60} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={100} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={90} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={55} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={60} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={100} />
|
||||
</TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactsSekeleton;
|
||||
31
frontend/src/components/MainContainer/index.js
Normal file
31
frontend/src/components/MainContainer/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Container from "@material-ui/core/Container";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
mainContainer: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(2),
|
||||
height: `calc(100% - 48px)`,
|
||||
},
|
||||
|
||||
contentWrapper: {
|
||||
height: "100%",
|
||||
overflowY: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}));
|
||||
|
||||
const MainContainer = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Container className={classes.mainContainer}>
|
||||
<div className={classes.contentWrapper}>{children}</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainContainer;
|
||||
19
frontend/src/components/MainHeader/index.js
Normal file
19
frontend/src/components/MainHeader/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contactsHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0px 6px 6px 6px",
|
||||
},
|
||||
}));
|
||||
|
||||
const MainHeader = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={classes.contactsHeader}>{children}</div>;
|
||||
};
|
||||
|
||||
export default MainHeader;
|
||||
21
frontend/src/components/MainHeaderButtonsWrapper/index.js
Normal file
21
frontend/src/components/MainHeaderButtonsWrapper/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
MainHeaderButtonsWrapper: {
|
||||
flex: "none",
|
||||
marginLeft: "auto",
|
||||
"& > *": {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MainHeaderButtonsWrapper = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={classes.MainHeaderButtonsWrapper}>{children}</div>;
|
||||
};
|
||||
|
||||
export default MainHeaderButtonsWrapper;
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
||||
import "emoji-mart/css/emoji-mart.css";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Picker } from "emoji-mart";
|
||||
import { toast } from "react-toastify";
|
||||
import MicRecorder from "mic-recorder-to-mp3";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
@@ -163,8 +164,10 @@ const MessageInput = () => {
|
||||
try {
|
||||
await api.post(`/messages/${ticketId}`, formData);
|
||||
} catch (err) {
|
||||
alert(err.response.data.error);
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
setMedia(mediaInitialState);
|
||||
@@ -182,8 +185,10 @@ const MessageInput = () => {
|
||||
try {
|
||||
await api.post(`/messages/${ticketId}`, message);
|
||||
} catch (err) {
|
||||
alert(err.response.data.error);
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
setInputMessage("");
|
||||
setShowEmoji(false);
|
||||
@@ -224,8 +229,10 @@ const MessageInput = () => {
|
||||
try {
|
||||
await api.post(`/messages/${ticketId}`, formData);
|
||||
} catch (err) {
|
||||
alert(err.response.data.error);
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
setRecording(false);
|
||||
setLoading(false);
|
||||
|
||||
@@ -101,15 +101,7 @@ const useStyles = makeStyles(theme => ({
|
||||
padding: "20px 20px 20px 20px",
|
||||
// scrollBehavior: "smooth",
|
||||
overflowY: "scroll",
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
// borderRadius: "2px",
|
||||
boxShadow: "inset 0 0 6px rgba(0, 0, 0, 0.3)",
|
||||
backgroundColor: "#e8e8e8",
|
||||
},
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
|
||||
circleLoading: {
|
||||
@@ -311,8 +303,12 @@ const MessagesList = () => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
toast.error("Ticket não encontrado");
|
||||
history.push("/chat");
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
if (err.response.status === 404) {
|
||||
history.push("/tickets");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchMessages();
|
||||
@@ -425,8 +421,11 @@ const MessagesList = () => {
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
history.push("/chat");
|
||||
history.push("/tickets");
|
||||
};
|
||||
|
||||
const handleDrawerOpen = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import Button from "@material-ui/core/Button";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
@@ -38,7 +39,7 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const NewTicketModal = ({ modalOpen, onClose, contactId }) => {
|
||||
const NewTicketModal = ({ modalOpen, onClose }) => {
|
||||
const history = useHistory();
|
||||
const classes = useStyles();
|
||||
const userId = +localStorage.getItem("userId");
|
||||
@@ -54,18 +55,21 @@ const NewTicketModal = ({ modalOpen, onClose, contactId }) => {
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
const res = await api.get("contacts", {
|
||||
params: { searchParam, rowsPerPage: 20 },
|
||||
const { data } = await api.get("contacts", {
|
||||
params: { searchParam },
|
||||
});
|
||||
setOptions(res.data.contacts);
|
||||
setOptions(data.contacts);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
alert(err);
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchContacts();
|
||||
}, 1000);
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, modalOpen]);
|
||||
|
||||
@@ -85,9 +89,12 @@ const NewTicketModal = ({ modalOpen, onClose, contactId }) => {
|
||||
userId: userId,
|
||||
status: "open",
|
||||
});
|
||||
history.push(`/chat/${ticket.id}`);
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
} catch (err) {
|
||||
alert(err);
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
@@ -107,13 +114,14 @@ const NewTicketModal = ({ modalOpen, onClose, contactId }) => {
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Autocomplete
|
||||
id="asynchronous-demo"
|
||||
id="contacts-finder"
|
||||
style={{ width: 300 }}
|
||||
getOptionLabel={option => `${option.name} - ${option.number}`}
|
||||
onChange={(e, newValue) => {
|
||||
setSelectedContact(newValue);
|
||||
}}
|
||||
options={options}
|
||||
noOptionsText="No contacts found. Try another term."
|
||||
loading={loading}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
|
||||
@@ -24,14 +24,7 @@ const useStyles = makeStyles(theme => ({
|
||||
tabContainer: {
|
||||
overflowY: "auto",
|
||||
maxHeight: 350,
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
boxShadow: "inset 0 0 6px rgba(0, 0, 0, 0.3)",
|
||||
backgroundColor: "#e8e8e8",
|
||||
},
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
popoverPaper: {
|
||||
width: "100%",
|
||||
@@ -123,7 +116,7 @@ const NotificationsPopOver = () => {
|
||||
|
||||
notification.onclick = function (event) {
|
||||
event.preventDefault(); //
|
||||
window.open(`/chat/${ticket.id}`, "_self");
|
||||
window.open(`/tickets/${ticket.id}`, "_self");
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
@@ -144,7 +137,7 @@ const NotificationsPopOver = () => {
|
||||
}, [setIsOpen]);
|
||||
|
||||
const handleSelectTicket = (e, ticket) => {
|
||||
history.push(`/chat/${ticket.id}`);
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
handleClickAway();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
import FirstPageIcon from "@material-ui/icons/FirstPage";
|
||||
import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft";
|
||||
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight";
|
||||
import LastPageIcon from "@material-ui/icons/LastPage";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
marginLeft: theme.spacing(2.5),
|
||||
},
|
||||
}));
|
||||
|
||||
const PaginationActions = ({ count, page, rowsPerPage, onChangePage }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const handleFirstPageButtonClick = event => {
|
||||
onChangePage(event, 0);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = event => {
|
||||
onChangePage(event, page - 1);
|
||||
};
|
||||
|
||||
const handleNextButtonClick = event => {
|
||||
onChangePage(event, page + 1);
|
||||
};
|
||||
|
||||
const handleLastPageButtonClick = event => {
|
||||
onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<IconButton
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
>
|
||||
{<FirstPageIcon />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
>
|
||||
{<KeyboardArrowLeft />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="next page"
|
||||
>
|
||||
{<KeyboardArrowRight />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleLastPageButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="last page"
|
||||
>
|
||||
{<LastPageIcon />}
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginationActions;
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from "react";
|
||||
import QRCode from "qrcode.react";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
const Qrcode = ({ qrCode }) => {
|
||||
return (
|
||||
<div>
|
||||
<Typography color="primary" gutterBottom>
|
||||
{i18n.t("qrCode.message")}
|
||||
</Typography>
|
||||
{qrCode ? <QRCode value={qrCode} size={256} /> : <span>loading</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Qrcode;
|
||||
65
frontend/src/components/QrcodeModal/index.js
Normal file
65
frontend/src/components/QrcodeModal/index.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import QRCode from "qrcode.react";
|
||||
import openSocket from "socket.io-client";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { Dialog, DialogContent, Paper, Typography } from "@material-ui/core";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
|
||||
const QrcodeModal = ({ open, onClose, whatsAppId }) => {
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/whatsapp/${whatsAppId}`);
|
||||
setQrCode(data.qrcode);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, [whatsAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!whatsAppId) return;
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.on("whatsappSession", data => {
|
||||
if (data.action === "update" && data.session.id === whatsAppId) {
|
||||
setQrCode(data.session.qrcode);
|
||||
}
|
||||
|
||||
if (data.action === "update" && data.session.qrcode === "") {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [whatsAppId, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" scroll="paper">
|
||||
<DialogContent>
|
||||
<Paper elevation={0}>
|
||||
<Typography color="primary" gutterBottom>
|
||||
{i18n.t("qrCode.message")}
|
||||
</Typography>
|
||||
{qrCode ? (
|
||||
<QRCode value={qrCode} size={256} />
|
||||
) : (
|
||||
<span>Waiting for QR Code</span>
|
||||
)}
|
||||
</Paper>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(QrcodeModal);
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
@@ -9,9 +10,12 @@ import api from "../../services/api";
|
||||
const SessionInfo = ({ session }) => {
|
||||
const handleDisconectSession = async () => {
|
||||
try {
|
||||
await api.delete("/whatsapp/session/1");
|
||||
await api.put(`/whatsapp/session/${session.id}`);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,7 +27,7 @@ const SessionInfo = ({ session }) => {
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{`${i18n.t("sessionInfo.updatedAt")}`}{" "}
|
||||
{session.updatedAt &&
|
||||
format(parseISO(session.updatedAt), "dd/mm/yy HH:mm")}
|
||||
format(parseISO(session.updatedAt), "dd/MM/yy HH:mm")}
|
||||
</Typography>
|
||||
<Button
|
||||
color="primary"
|
||||
|
||||
78
frontend/src/components/TableRowSkeleton/index.js
Normal file
78
frontend/src/components/TableRowSkeleton/index.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import Skeleton from "@material-ui/lab/Skeleton";
|
||||
|
||||
const TableRowSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={80} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={20} width={70} />
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell align="right"></TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableRowSkeleton;
|
||||
@@ -100,11 +100,11 @@ const TicketListItem = ({ ticket }) => {
|
||||
} catch (err) {
|
||||
alert(err);
|
||||
}
|
||||
history.push(`/chat/${ticketId}`);
|
||||
history.push(`/tickets/${ticketId}`);
|
||||
};
|
||||
|
||||
const handleSelectTicket = (e, ticket) => {
|
||||
history.push(`/chat/${ticket.id}`);
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,7 +18,7 @@ const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
||||
try {
|
||||
await api.delete(`/tickets/${ticket.id}`);
|
||||
toast.success("Ticket deletado com sucesso.");
|
||||
history.push("/chat");
|
||||
history.push("/tickets");
|
||||
} catch (err) {
|
||||
toast.error("Erro ao deletar o ticket");
|
||||
}
|
||||
|
||||
@@ -23,14 +23,7 @@ const useStyles = makeStyles(theme => ({
|
||||
ticketsList: {
|
||||
flex: 1,
|
||||
overflowY: "scroll",
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
boxShadow: "inset 0 0 6px rgba(0, 0, 0, 0.3)",
|
||||
backgroundColor: "#e8e8e8",
|
||||
},
|
||||
...theme.scrollbarStyles,
|
||||
borderTop: "2px solid rgba(0, 0, 0, 0.12)",
|
||||
},
|
||||
|
||||
|
||||
10
frontend/src/components/Title/index.js
Normal file
10
frontend/src/components/Title/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
export default function Title(props) {
|
||||
return (
|
||||
<Typography variant="h5" color="primary" gutterBottom>
|
||||
{props.children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/UserModal/index.js
Normal file
224
frontend/src/components/UserModal/index.js
Normal file
@@ -0,0 +1,224 @@
|
||||
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 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 CircularProgress from "@material-ui/core/CircularProgress";
|
||||
import Select from "@material-ui/core/Select";
|
||||
import InputLabel from "@material-ui/core/InputLabel";
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
|
||||
// import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
textField: {
|
||||
// marginLeft: theme.spacing(1),
|
||||
marginRight: theme.spacing(1),
|
||||
// width: "25ch",
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
// margin: theme.spacing(1),
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
formControl: {
|
||||
margin: theme.spacing(1),
|
||||
minWidth: 120,
|
||||
},
|
||||
}));
|
||||
|
||||
const UserSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(50, "Too Long!")
|
||||
.required("Required"),
|
||||
password: Yup.string().min(5, "Too Short!").max(50, "Too Long!"),
|
||||
email: Yup.string().email("Invalid email").required("Required"),
|
||||
});
|
||||
|
||||
const UserModal = ({ open, onClose, userId }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const initialState = {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
profile: "user",
|
||||
};
|
||||
|
||||
const [user, setUser] = useState(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const { data } = await api.get(`/users/${userId}`);
|
||||
setUser(prevState => {
|
||||
return { ...prevState, ...data };
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [userId, open]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setUser(initialState);
|
||||
};
|
||||
|
||||
const handleSaveUser = async values => {
|
||||
try {
|
||||
if (userId) {
|
||||
await api.put(`/users/${userId}`, values);
|
||||
} else {
|
||||
await api.post("/users", values);
|
||||
}
|
||||
toast.success("Success!");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="lg" scroll="paper">
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{userId ? `Edit User` : `New User`}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={user}
|
||||
enableReinitialize={true}
|
||||
validationSchema={UserSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveUser(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ touched, errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<Field
|
||||
as={TextField}
|
||||
label="Name"
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<Field
|
||||
as={TextField}
|
||||
label="Email"
|
||||
name="email"
|
||||
error={touched.email && Boolean(errors.email)}
|
||||
helperText={touched.email && errors.email}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
<div>
|
||||
<Field
|
||||
as={TextField}
|
||||
label="New Password"
|
||||
type="password"
|
||||
name="password"
|
||||
error={touched.password && Boolean(errors.password)}
|
||||
helperText={touched.password && errors.password}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
<FormControl
|
||||
variant="outlined"
|
||||
className={classes.formControl}
|
||||
margin="dense"
|
||||
>
|
||||
<InputLabel id="profile-selection-input-label">
|
||||
Profile
|
||||
</InputLabel>
|
||||
<Field
|
||||
as={Select}
|
||||
label="Profile"
|
||||
name="profile"
|
||||
labelId="profile-selection-label"
|
||||
id="profile-selection"
|
||||
required
|
||||
>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="user">User</MenuItem>
|
||||
</Field>
|
||||
</FormControl>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{"Ok"}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserModal;
|
||||
182
frontend/src/components/WhatsAppModal/index.js
Normal file
182
frontend/src/components/WhatsAppModal/index.js
Normal file
@@ -0,0 +1,182 @@
|
||||
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,
|
||||
} from "@material-ui/core";
|
||||
|
||||
// import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
form: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifySelf: "center",
|
||||
"& > *": {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
|
||||
textField: {
|
||||
flex: 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: "",
|
||||
default: false,
|
||||
};
|
||||
const [whatsApp, setWhatsApp] = useState(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
if (!whatsAppId) return;
|
||||
|
||||
try {
|
||||
const { data } = await api.get(`whatsapp/${whatsAppId}`);
|
||||
setWhatsApp(data);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, [whatsAppId]);
|
||||
|
||||
const handleSaveWhatsApp = async values => {
|
||||
try {
|
||||
if (whatsAppId) {
|
||||
await api.put(`/whatsapp/${whatsAppId}`, {
|
||||
name: values.name,
|
||||
default: values.default,
|
||||
});
|
||||
} else {
|
||||
await api.post("/whatsapp", values);
|
||||
}
|
||||
toast.success("Success!");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setWhatsApp(initialState);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="lg" scroll="paper">
|
||||
<DialogTitle>WhatsApp</DialogTitle>
|
||||
<Formik
|
||||
initialValues={whatsApp}
|
||||
enableReinitialize={true}
|
||||
validationSchema={SessionSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveWhatsApp(values);
|
||||
// alert(JSON.stringify(values, null, 2));
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ values, touched, errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers className={classes.form}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label="Name"
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Field
|
||||
as={Switch}
|
||||
color="primary"
|
||||
name="default"
|
||||
checked={values.default}
|
||||
/>
|
||||
}
|
||||
label="Default"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
Save
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(WhatsAppModal);
|
||||
@@ -38,16 +38,17 @@ function ListItemLink(props) {
|
||||
}
|
||||
|
||||
const MainListItems = () => {
|
||||
const userProfile = localStorage.getItem("profile");
|
||||
return (
|
||||
<div>
|
||||
<ListItemLink to="/" primary="Dashboard" icon={<DashboardIcon />} />
|
||||
<ListItemLink
|
||||
to="/whats-auth"
|
||||
primary={i18n.t("mainDrawer.listItems.connection")}
|
||||
to="/connections"
|
||||
primary="Connections"
|
||||
icon={<SyncAltIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/chat"
|
||||
to="/tickets"
|
||||
primary={i18n.t("mainDrawer.listItems.tickets")}
|
||||
icon={<WhatsAppIcon />}
|
||||
/>
|
||||
@@ -57,18 +58,22 @@ const MainListItems = () => {
|
||||
primary={i18n.t("mainDrawer.listItems.contacts")}
|
||||
icon={<ContactPhoneIcon />}
|
||||
/>
|
||||
<Divider />
|
||||
<ListSubheader inset>Administration</ListSubheader>
|
||||
<ListItemLink
|
||||
to="/chat"
|
||||
primary={i18n.t("mainDrawer.listItems.users")}
|
||||
icon={<GroupIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/chat"
|
||||
primary={i18n.t("mainDrawer.listItems.settings")}
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
{userProfile === "admin" && (
|
||||
<>
|
||||
<Divider />
|
||||
<ListSubheader inset>Administration</ListSubheader>
|
||||
<ListItemLink
|
||||
to="/users"
|
||||
primary={i18n.t("mainDrawer.listItems.users")}
|
||||
icon={<GroupIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/settings"
|
||||
primary={i18n.t("mainDrawer.listItems.settings")}
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,7 +35,10 @@ const useAuth = () => {
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
setIsAuth(false);
|
||||
toast.error(i18n.t("auth.toasts.fail"));
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
@@ -44,16 +47,20 @@ const useAuth = () => {
|
||||
const handleLogin = async (e, user) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await api.post("/auth/login", user);
|
||||
localStorage.setItem("token", JSON.stringify(res.data.token));
|
||||
localStorage.setItem("username", res.data.username);
|
||||
localStorage.setItem("userId", res.data.userId);
|
||||
api.defaults.headers.Authorization = `Bearer ${res.data.token}`;
|
||||
const { data } = await api.post("/auth/login", user);
|
||||
localStorage.setItem("token", JSON.stringify(data.token));
|
||||
localStorage.setItem("username", data.username);
|
||||
localStorage.setItem("profile", data.profile);
|
||||
localStorage.setItem("userId", data.userId);
|
||||
api.defaults.headers.Authorization = `Bearer ${data.token}`;
|
||||
setIsAuth(true);
|
||||
toast.success(i18n.t("auth.toasts.success"));
|
||||
history.push("/chat");
|
||||
history.push("/tickets");
|
||||
} catch (err) {
|
||||
toast.error(i18n.t("auth.toasts.fail"));
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,6 +69,7 @@ const useAuth = () => {
|
||||
setIsAuth(false);
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("username");
|
||||
localStorage.removeItem("profile");
|
||||
localStorage.removeItem("userId");
|
||||
api.defaults.headers.Authorization = undefined;
|
||||
history.push("/login");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import openSocket from "socket.io-client";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import api from "../../services/api";
|
||||
|
||||
@@ -97,6 +98,9 @@ const useTickets = ({ searchParam, pageNumber, status, date, showAll }) => {
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchTickets();
|
||||
|
||||
297
frontend/src/pages/Connections/index.js
Normal file
297
frontend/src/pages/Connections/index.js
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useState, useEffect, useReducer, useCallback } from "react";
|
||||
import openSocket from "socket.io-client";
|
||||
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,
|
||||
} from "@material-ui/core";
|
||||
import { Edit, DeleteOutline, CheckCircle } 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";
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_WHATSAPPS") {
|
||||
const whatsApps = action.payload;
|
||||
|
||||
return [...whatsApps];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_WHATSAPPS") {
|
||||
const whatsApp = action.payload;
|
||||
const whatsAppIndex = state.findIndex(s => s.id === whatsApp.id);
|
||||
|
||||
if (whatsAppIndex !== -1) {
|
||||
state[whatsAppIndex] = whatsApp;
|
||||
return [...state];
|
||||
} else {
|
||||
return [whatsApp, ...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_SESSION") {
|
||||
const whatsApp = action.payload;
|
||||
const whatsAppIndex = state.findIndex(s => s.id === whatsApp.id);
|
||||
|
||||
if (whatsAppIndex !== -1) {
|
||||
state[whatsAppIndex].status = whatsApp.status;
|
||||
state[whatsAppIndex].qrcode = whatsApp.qrcode;
|
||||
return [...state];
|
||||
} else {
|
||||
return [...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_WHATSAPPS") {
|
||||
const whatsAppId = action.payload;
|
||||
|
||||
const whatsAppIndex = state.findIndex(s => s.id === whatsAppId);
|
||||
if (whatsAppIndex !== -1) {
|
||||
state.splice(whatsAppIndex, 1);
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
}));
|
||||
|
||||
const Connections = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [whatsApps, dispatch] = useReducer(reducer, []);
|
||||
|
||||
const [whatsAppModalOpen, setWhatsAppModalOpen] = useState(false);
|
||||
const [qrModalOpen, setQrModalOpen] = useState(false);
|
||||
const [selectedWhatsApp, setSelectedWhatsApp] = useState(null);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
const [deletingWhatsApp, setDeletingWhatsApp] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/whatsapp/");
|
||||
dispatch({ type: "LOAD_WHATSAPPS", payload: data });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.on("whatsapp", data => {
|
||||
if (data.action === "update") {
|
||||
dispatch({ type: "UPDATE_WHATSAPPS", payload: data.whatsapp });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("whatsapp", data => {
|
||||
if (data.action === "delete") {
|
||||
dispatch({ type: "DELETE_WHATSAPPS", payload: data.whatsappId });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("whatsappSession", data => {
|
||||
if (data.action === "update") {
|
||||
dispatch({ type: "UPDATE_SESSION", payload: data.session });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// const handleDisconnectSession = async whatsAppId => {
|
||||
// try {
|
||||
// await api.put(`/whatsapp/whatsApp/${whatsAppId}`, {
|
||||
// whatsApp: "",
|
||||
// status: "pending",
|
||||
// });
|
||||
// } catch (err) {
|
||||
// console.log(err);
|
||||
// if (err.response && err.response.data && err.response.data.error) {
|
||||
// toast.error(err.response.data.error);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
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(() => {
|
||||
setQrModalOpen(false);
|
||||
setSelectedWhatsApp(null);
|
||||
}, [setQrModalOpen, setSelectedWhatsApp]);
|
||||
|
||||
const handleEditWhatsApp = whatsApp => {
|
||||
setSelectedWhatsApp(whatsApp);
|
||||
setWhatsAppModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteWhatsApp = async whatsAppId => {
|
||||
try {
|
||||
await api.delete(`/whatsapp/${whatsAppId}`);
|
||||
toast.success("Deleted!");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
setDeletingWhatsApp(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContainer>
|
||||
<ConfirmationModal
|
||||
title={deletingWhatsApp && `Delete ${deletingWhatsApp.name}?`}
|
||||
open={confirmModalOpen}
|
||||
setOpen={setConfirmModalOpen}
|
||||
onConfirm={() => handleDeleteWhatsApp(deletingWhatsApp.id)}
|
||||
>
|
||||
Are you sure? It cannot be reverted.
|
||||
</ConfirmationModal>
|
||||
<QrcodeModal
|
||||
open={qrModalOpen}
|
||||
onClose={handleCloseQrModal}
|
||||
whatsAppId={
|
||||
selectedWhatsApp && !whatsAppModalOpen && selectedWhatsApp.id
|
||||
}
|
||||
/>
|
||||
<WhatsAppModal
|
||||
open={whatsAppModalOpen}
|
||||
onClose={handleCloseWhatsAppModal}
|
||||
whatsAppId={selectedWhatsApp && !qrModalOpen && selectedWhatsApp.id}
|
||||
/>
|
||||
<MainHeader>
|
||||
<Title>Connections</Title>
|
||||
<MainHeaderButtonsWrapper>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleOpenWhatsAppModal}
|
||||
>
|
||||
Add Whatsapp
|
||||
</Button>
|
||||
</MainHeaderButtonsWrapper>
|
||||
</MainHeader>
|
||||
<Paper className={classes.mainPaper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">Name</TableCell>
|
||||
<TableCell align="center">Status</TableCell>
|
||||
<TableCell align="center">Last update</TableCell>
|
||||
<TableCell align="center">Default</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{false ? (
|
||||
<TableRowSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{whatsApps &&
|
||||
whatsApps.length > 0 &&
|
||||
whatsApps.map((whatsApp, index) => (
|
||||
<TableRow key={whatsApp.id}>
|
||||
<TableCell align="center">{whatsApp.name}</TableCell>
|
||||
<TableCell align="center">
|
||||
{whatsApp.status === "qrcode" ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => handleOpenQrModal(whatsApp)}
|
||||
>
|
||||
QR CODE
|
||||
</Button>
|
||||
) : (
|
||||
whatsApp.status
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{format(parseISO(whatsApp.updatedAt), "dd/MM/yy HH:mm")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{whatsApp.default && (
|
||||
<CheckCircle style={{ color: green[500] }} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditWhatsApp(whatsApp)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
setConfirmModalOpen(true);
|
||||
setDeletingWhatsApp(whatsApp);
|
||||
}}
|
||||
>
|
||||
<DeleteOutline />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Connections;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useReducer } from "react";
|
||||
import openSocket from "socket.io-client";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Table from "@material-ui/core/Table";
|
||||
@@ -10,113 +11,129 @@ 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 TableFooter from "@material-ui/core/TableFooter";
|
||||
import TablePagination from "@material-ui/core/TablePagination";
|
||||
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Container from "@material-ui/core/Container";
|
||||
import InputAdornment from "@material-ui/core/InputAdornment";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
|
||||
import EditIcon from "@material-ui/icons/Edit";
|
||||
|
||||
import PaginationActions from "../../components/PaginationActions";
|
||||
import api from "../../services/api";
|
||||
import ContactsSekeleton from "../../components/ContactsSekeleton";
|
||||
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";
|
||||
|
||||
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 => ({
|
||||
mainContainer: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(2),
|
||||
height: `calc(100% - 48px)`,
|
||||
},
|
||||
|
||||
contentWrapper: {
|
||||
height: "100%",
|
||||
overflowY: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
|
||||
contactsHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0px 6px 6px 6px",
|
||||
},
|
||||
|
||||
actionButtons: {
|
||||
flex: "none",
|
||||
marginLeft: "auto",
|
||||
"& > *": {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(2),
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
boxShadow: "inset 0 0 6px rgba(0, 0, 0, 0.3)",
|
||||
backgroundColor: "#e8e8e8",
|
||||
},
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
}));
|
||||
|
||||
const Contacts = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [searchParam, setSearchParam] = useState("");
|
||||
const [contacts, setContacts] = 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 res = await api.get("/contacts/", {
|
||||
params: { searchParam, pageNumber: page + 1, rowsPerPage },
|
||||
const { data } = await api.get("/contacts/", {
|
||||
params: { searchParam, pageNumber },
|
||||
});
|
||||
setContacts(res.data.contacts);
|
||||
setCount(res.data.count);
|
||||
dispatch({ type: "LOAD_CONTACTS", payload: data.contacts });
|
||||
setHasMore(data.hasMore);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
alert(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchContacts();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, page, rowsPerPage]);
|
||||
}, [searchParam, pageNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
socket.on("contact", data => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
updateContacts(data.contact);
|
||||
dispatch({ type: "UPDATE_CONTACTS", payload: data.contact });
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
deleteContact(data.contactId);
|
||||
dispatch({ type: "DELETE_CONTACT", payload: +data.contactId });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -125,40 +142,6 @@ const Contacts = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateContacts = contact => {
|
||||
setContacts(prevState => {
|
||||
const contactIndex = prevState.findIndex(c => c.id === contact.id);
|
||||
|
||||
if (contactIndex === -1) {
|
||||
return [contact, ...prevState];
|
||||
}
|
||||
const aux = [...prevState];
|
||||
aux[contactIndex] = contact;
|
||||
return aux;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteContact = contactId => {
|
||||
setContacts(prevState => {
|
||||
const contactIndex = prevState.findIndex(c => c.id === +contactId);
|
||||
|
||||
if (contactIndex === -1) return prevState;
|
||||
|
||||
const aux = [...prevState];
|
||||
aux.splice(contactIndex, 1);
|
||||
return aux;
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = event => {
|
||||
setRowsPerPage(+event.target.value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleSearch = event => {
|
||||
setSearchParam(event.target.value.toLowerCase());
|
||||
};
|
||||
@@ -181,24 +164,42 @@ const Contacts = () => {
|
||||
const handleDeleteContact = async contactId => {
|
||||
try {
|
||||
await api.delete(`/contacts/${contactId}`);
|
||||
toast.success("Contact deleted sucessfully!");
|
||||
} catch (err) {
|
||||
alert(err);
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
setDeletingContact(null);
|
||||
setSearchParam("");
|
||||
setPage(0);
|
||||
setPageNumber(1);
|
||||
};
|
||||
|
||||
const handleimportContact = async () => {
|
||||
try {
|
||||
await api.post("/contacts/import");
|
||||
window.location.reload(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
window.location.reload(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Container className={classes.mainContainer}>
|
||||
<MainContainer className={classes.mainContainer}>
|
||||
<ContactModal
|
||||
open={contactModalOpen}
|
||||
onClose={handleCloseContactModal}
|
||||
@@ -225,112 +226,91 @@ const Contacts = () => {
|
||||
? `${i18n.t("contacts.confirmationModal.deleteMessage")}`
|
||||
: `${i18n.t("contacts.confirmationModal.importMessage")}`}
|
||||
</ConfirmationModal>
|
||||
<div className={classes.contentWrapper}>
|
||||
<div className={classes.contactsHeader}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{i18n.t("contacts.title")}
|
||||
</Typography>
|
||||
<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>{i18n.t("contacts.table.whatsapp")}</TableCell>
|
||||
<TableCell>{i18n.t("contacts.table.email")}</TableCell>
|
||||
<TableCell align="right">
|
||||
{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>{contact.number}</TableCell>
|
||||
<TableCell>{contact.email}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => hadleEditContact(contact.id)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
|
||||
<div className={classes.actionButtons}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<Paper className={classes.mainPaper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox" />
|
||||
<TableCell>{i18n.t("contacts.table.name")}</TableCell>
|
||||
<TableCell>{i18n.t("contacts.table.whatsapp")}</TableCell>
|
||||
<TableCell>{i18n.t("contacts.table.email")}</TableCell>
|
||||
<TableCell align="right">
|
||||
{i18n.t("contacts.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<ContactsSekeleton />
|
||||
) : (
|
||||
<>
|
||||
{contacts.map(contact => (
|
||||
<TableRow key={contact.id}>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
{<Avatar src={contact.profilePicUrl} />}
|
||||
</TableCell>
|
||||
<TableCell>{contact.name}</TableCell>
|
||||
<TableCell>{contact.number}</TableCell>
|
||||
<TableCell>{contact.email}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => hadleEditContact(contact.id)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
setConfirmOpen(true);
|
||||
setDeletingContact(contact);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TablePagination
|
||||
colSpan={5}
|
||||
count={count}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
SelectProps={{
|
||||
inputProps: { "aria-label": "rows per page" },
|
||||
native: true,
|
||||
}}
|
||||
onChangePage={handleChangePage}
|
||||
onChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
ActionsComponent={PaginationActions}
|
||||
/>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Paper>
|
||||
</div>
|
||||
</Container>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
setConfirmOpen(true);
|
||||
setDeletingContact(contact);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loading && <TableRowSkeleton />}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
export default function Title(props) {
|
||||
const Title = props => {
|
||||
return (
|
||||
<Typography component="h2" variant="h6" color="primary" gutterBottom>
|
||||
{props.children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Title;
|
||||
|
||||
124
frontend/src/pages/Settings/index.js
Normal file
124
frontend/src/pages/Settings/index.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import openSocket from "socket.io-client";
|
||||
|
||||
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 { toast } from "react-toastify";
|
||||
|
||||
import api from "../../services/api";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing(4),
|
||||
},
|
||||
|
||||
paper: {
|
||||
padding: theme.spacing(2),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
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) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
socket.on("settings", data => {
|
||||
if (data.action === "update") {
|
||||
// dispatch({ type: "UPDATE_USERS", payload: data.user });
|
||||
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("Setting updated");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSettingValue = key => {
|
||||
const { value } = settings.find(s => s.key === key);
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Container className={classes.container} maxWidth="sm">
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Settings
|
||||
</Typography>
|
||||
<Paper className={classes.paper}>
|
||||
<Typography variant="body1">User creation</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">Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</Select>
|
||||
</Paper>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
|
||||
import Avatar from "@material-ui/core/Avatar";
|
||||
import Button from "@material-ui/core/Button";
|
||||
@@ -53,24 +55,33 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const UserSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(50, "Too Long!")
|
||||
.required("Required"),
|
||||
password: Yup.string().min(5, "Too Short!").max(50, "Too Long!"),
|
||||
email: Yup.string().email("Invalid email").required("Required"),
|
||||
});
|
||||
|
||||
const SignUp = () => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
|
||||
const [user, setUser] = useState({ name: "", email: "", password: "" });
|
||||
const initialState = { name: "", email: "", password: "" };
|
||||
|
||||
const handleChangeInput = e => {
|
||||
setUser({ ...user, [e.target.name]: e.target.value });
|
||||
};
|
||||
const [user] = useState(initialState);
|
||||
|
||||
const handleSignUp = async e => {
|
||||
e.preventDefault();
|
||||
const handleSignUp = async values => {
|
||||
try {
|
||||
await api.post("/users", user);
|
||||
await api.post("/auth/signup", values);
|
||||
toast.success(i18n.t("signup.toasts.success"));
|
||||
history.push("/login");
|
||||
} catch (err) {
|
||||
toast.error(i18n.t("signup.toasts.fail"));
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,68 +95,88 @@ const SignUp = () => {
|
||||
<Typography component="h1" variant="h5">
|
||||
{i18n.t("signup.title")}
|
||||
</Typography>
|
||||
<form className={classes.form} noValidate onSubmit={handleSignUp}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
autoComplete="name"
|
||||
name="name"
|
||||
variant="outlined"
|
||||
required
|
||||
fullWidth
|
||||
id="name"
|
||||
label={i18n.t("signup.form.name")}
|
||||
value={user.name}
|
||||
onChange={handleChangeInput}
|
||||
autoFocus
|
||||
/>
|
||||
</Grid>
|
||||
{/* <form className={classes.form} noValidate onSubmit={handleSignUp}> */}
|
||||
<Formik
|
||||
initialValues={user}
|
||||
enableReinitialize={true}
|
||||
validationSchema={UserSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSignUp(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ touched, errors, isSubmitting }) => (
|
||||
<Form className={classes.form}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Field
|
||||
as={TextField}
|
||||
autoComplete="name"
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
id="name"
|
||||
label={i18n.t("signup.form.name")}
|
||||
autoFocus
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
required
|
||||
<Grid item xs={12}>
|
||||
<Field
|
||||
as={TextField}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
id="email"
|
||||
label={i18n.t("signup.form.email")}
|
||||
name="email"
|
||||
error={touched.email && Boolean(errors.email)}
|
||||
helperText={touched.email && errors.email}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Field
|
||||
as={TextField}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
name="password"
|
||||
error={touched.password && Boolean(errors.password)}
|
||||
helperText={touched.password && errors.password}
|
||||
label={i18n.t("signup.form.password")}
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
id="email"
|
||||
label={i18n.t("signup.form.email")}
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={user.email}
|
||||
onChange={handleChangeInput}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label={i18n.t("signup.form.password")}
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
value={user.password}
|
||||
onChange={handleChangeInput}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.submit}
|
||||
>
|
||||
{i18n.t("signup.buttons.submit")}
|
||||
</Button>
|
||||
<Grid container justify="flex-end">
|
||||
<Grid item>
|
||||
<Link href="#" variant="body2" component={RouterLink} to="/login">
|
||||
{i18n.t("signup.buttons.login")}
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.submit}
|
||||
>
|
||||
{i18n.t("signup.buttons.submit")}
|
||||
</Button>
|
||||
<Grid container justify="flex-end">
|
||||
<Grid item>
|
||||
<Link
|
||||
href="#"
|
||||
variant="body2"
|
||||
component={RouterLink}
|
||||
to="/login"
|
||||
>
|
||||
{i18n.t("signup.buttons.login")}
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
<Box mt={5}>
|
||||
<Copyright />
|
||||
|
||||
282
frontend/src/pages/Users/index.js
Normal file
282
frontend/src/pages/Users/index.js
Normal file
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useEffect, useReducer } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import openSocket from "socket.io-client";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import Button from "@material-ui/core/Button";
|
||||
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 IconButton from "@material-ui/core/IconButton";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import InputAdornment from "@material-ui/core/InputAdornment";
|
||||
|
||||
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
|
||||
import EditIcon from "@material-ui/icons/Edit";
|
||||
|
||||
import MainContainer from "../../components/MainContainer";
|
||||
import MainHeader from "../../components/MainHeader";
|
||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||
import Title from "../../components/Title";
|
||||
|
||||
import api from "../../services/api";
|
||||
import TableRowSkeleton from "../../components/TableRowSkeleton";
|
||||
import UserModal from "../../components/UserModal";
|
||||
import ConfirmationModal from "../../components/ConfirmationModal";
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_USERS") {
|
||||
const users = action.payload;
|
||||
const newUsers = [];
|
||||
|
||||
users.forEach(user => {
|
||||
const userIndex = state.findIndex(u => u.id === user.id);
|
||||
if (userIndex !== -1) {
|
||||
state[userIndex] = user;
|
||||
} else {
|
||||
newUsers.push(user);
|
||||
}
|
||||
});
|
||||
|
||||
return [...state, ...newUsers];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_USERS") {
|
||||
const user = action.payload;
|
||||
const userIndex = state.findIndex(u => u.id === user.id);
|
||||
|
||||
if (userIndex !== -1) {
|
||||
state[userIndex] = user;
|
||||
return [...state];
|
||||
} else {
|
||||
return [user, ...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_USER") {
|
||||
const userId = action.payload;
|
||||
|
||||
const userIndex = state.findIndex(u => u.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
state.splice(userIndex, 1);
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
}));
|
||||
|
||||
const Users = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [deletingUser, setDeletingUser] = useState(null);
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
const [searchParam, setSearchParam] = useState("");
|
||||
const [users, dispatch] = useReducer(reducer, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
setPageNumber(1);
|
||||
}, [searchParam]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/users/", {
|
||||
params: { searchParam, pageNumber },
|
||||
});
|
||||
dispatch({ type: "LOAD_USERS", payload: data.users });
|
||||
setHasMore(data.hasMore);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchUsers();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, pageNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
socket.on("user", data => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
dispatch({ type: "UPDATE_USERS", payload: data.user });
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
dispatch({ type: "DELETE_USER", payload: +data.userId });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenUserModal = () => {
|
||||
setSelectedUser(null);
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseUserModal = () => {
|
||||
setSelectedUser(null);
|
||||
setUserModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSearch = event => {
|
||||
setSearchParam(event.target.value.toLowerCase());
|
||||
};
|
||||
|
||||
const handleEditUser = user => {
|
||||
setSelectedUser(user);
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async userId => {
|
||||
try {
|
||||
await api.delete(`/users/${userId}`);
|
||||
toast.success("User deleted!");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
toast.error(err.response.data.error);
|
||||
}
|
||||
}
|
||||
setDeletingUser(null);
|
||||
setSearchParam("");
|
||||
setPageNumber(1);
|
||||
};
|
||||
|
||||
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>
|
||||
<ConfirmationModal
|
||||
title={deletingUser && `Delete ${deletingUser.name}?`}
|
||||
open={confirmModalOpen}
|
||||
setOpen={setConfirmModalOpen}
|
||||
onConfirm={e => handleDeleteUser(deletingUser.id)}
|
||||
>
|
||||
Are you sure? It canoot be reverted.
|
||||
</ConfirmationModal>
|
||||
<UserModal
|
||||
open={userModalOpen}
|
||||
onClose={handleCloseUserModal}
|
||||
aria-labelledby="form-dialog-title"
|
||||
userId={selectedUser && selectedUser.id}
|
||||
/>
|
||||
<MainHeader>
|
||||
<Title>Usuários</Title>
|
||||
<MainHeaderButtonsWrapper>
|
||||
<TextField
|
||||
placeholder="Search..."
|
||||
type="search"
|
||||
value={searchParam}
|
||||
onChange={handleSearch}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon style={{ color: "gray" }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleOpenUserModal}
|
||||
>
|
||||
Novo Usuário
|
||||
</Button>
|
||||
</MainHeaderButtonsWrapper>
|
||||
</MainHeader>
|
||||
<Paper
|
||||
className={classes.mainPaper}
|
||||
variant="outlined"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Profile</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRowSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{users.map(user => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.profile}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditUser(user)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
setConfirmModalOpen(true);
|
||||
setDeletingUser(user);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
@@ -1,104 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import api from "../../services/api";
|
||||
import openSocket from "socket.io-client";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import SessionInfo from "../../components/SessionInfo";
|
||||
import Qrcode from "../../components/Qrcode";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing(4),
|
||||
},
|
||||
|
||||
paper: {
|
||||
padding: theme.spacing(2),
|
||||
display: "flex",
|
||||
width: 400,
|
||||
overflow: "auto",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
fixedHeight: {
|
||||
height: 640,
|
||||
},
|
||||
}));
|
||||
|
||||
const WhatsAuth = () => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
const [session, setSession] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/whatsapp/session/1");
|
||||
setQrCode(data.qrcode);
|
||||
setSession(data);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket(process.env.REACT_APP_BACKEND_URL);
|
||||
|
||||
socket.on("session", data => {
|
||||
if (data.action === "update") {
|
||||
setQrCode(data.qr);
|
||||
setSession(data.session);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("session", data => {
|
||||
if (data.action === "update") {
|
||||
setSession(data.session);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("session", data => {
|
||||
if (data.action === "authentication") {
|
||||
setQrCode("");
|
||||
setSession(data.session);
|
||||
history.push("/chat");
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("session", data => {
|
||||
if (data.action === "logout") {
|
||||
setSession(data.session);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [history]);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{session && session.status === "disconnected" ? (
|
||||
<Paper className={classes.paper}>
|
||||
<Qrcode qrCode={qrCode} />
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper className={classes.paper}>
|
||||
<SessionInfo session={session} />
|
||||
</Paper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatsAuth;
|
||||
@@ -4,10 +4,12 @@ import { ToastContainer } from "react-toastify";
|
||||
|
||||
import MainDrawer from "../components/_layout";
|
||||
import Dashboard from "../pages/Dashboard/";
|
||||
import Chat from "../pages/Chat/";
|
||||
import Tickets from "../pages/Tickets/";
|
||||
import Signup from "../pages/Signup/";
|
||||
import Login from "../pages/Login/";
|
||||
import WhatsAuth from "../pages/WhatsAuth/WhatsAuth";
|
||||
import Connections from "../pages/Connections/";
|
||||
import Settings from "../pages/Settings/";
|
||||
import Users from "../pages/Users";
|
||||
import Contacts from "../pages/Contacts/";
|
||||
import { AuthProvider } from "../context/Auth/AuthContext";
|
||||
import Route from "./Route";
|
||||
@@ -21,9 +23,21 @@ const Routes = () => {
|
||||
<Route exact path="/signup" component={Signup} />
|
||||
<MainDrawer>
|
||||
<Route exact path="/" component={Dashboard} isPrivate />
|
||||
<Route exact path="/chat/:ticketId?" component={Chat} isPrivate />
|
||||
<Route exact path="/whats-auth" component={WhatsAuth} isPrivate />
|
||||
<Route
|
||||
exact
|
||||
path="/tickets/:ticketId?"
|
||||
component={Tickets}
|
||||
isPrivate
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/connections"
|
||||
component={Connections}
|
||||
isPrivate
|
||||
/>
|
||||
<Route exact path="/contacts" component={Contacts} isPrivate />
|
||||
<Route exact path="/users" component={Users} isPrivate />
|
||||
<Route exact path="/Settings" component={Settings} isPrivate />
|
||||
</MainDrawer>
|
||||
</Switch>
|
||||
<ToastContainer autoClose={3000} />
|
||||
|
||||
BIN
images/multiple-whatsapps2.png
Normal file
BIN
images/multiple-whatsapps2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Reference in New Issue
Block a user