commit 0cc863139d6ba04fc8915458058eb8923f582ae8 Author: adriano Date: Wed Jan 5 22:26:15 2022 -0300 Projeto em estado funcional sem modificações no layout diff --git a/.fossa.yml b/.fossa.yml new file mode 100755 index 0000000..370e799 --- /dev/null +++ b/.fossa.yml @@ -0,0 +1,26 @@ +# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) +# Visit https://fossa.com to learn more + +version: 2 +cli: + server: https://app.fossa.com + fetcher: custom + project: https://github.com/canove/whaticket.git +analyze: + modules: + - name: backend + type: npm + target: backend + path: backend + - name: backend + type: npm + target: backend + path: backend + - name: frontend + type: npm + target: frontend + path: frontend + - name: frontend + type: npm + target: frontend + path: frontend diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..394a813 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,19 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 10 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 10 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - bug + - enhancement +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..4f9a2de --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1 @@ +sonar.exclusions=frontend/src/translate/languages/*,**/__tests__/**/* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d6b90ad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 canove + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7457c2 --- /dev/null +++ b/README.md @@ -0,0 +1,441 @@ +[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate?business=VWW3BHW4AWHUY&item_name=Desenvolvimento+de+Software¤cy_code=BRL) +[![FOSSA Status](https://app.fossa.com/api/projects/custom%2B21084%2Fgithub.com%2Fcanove%2Fwhaticket.svg?type=shield)](https://app.fossa.com/projects/custom%2B21084%2Fgithub.com%2Fcanove%2Fwhaticket?ref=badge_shield) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=canove_whaticket&metric=alert_status)](https://sonarcloud.io/dashboard?id=canove_whaticket) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=canove_whaticket&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=canove_whaticket) +[![Discord Chat](https://img.shields.io/discord/784109818247774249.svg?logo=discord)](https://discord.gg/Dp2tTZRYHg) + +# WhaTicket + +**NOTE**: The new version of whatsapp-web.js required Node 14. Upgrade your installations to keep using it. + +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 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. + +## Motivation + +I'm a SysAdmin, and in my daily work, I do a lot of support through WhatsApp. Since WhatsApp Web doesn't allow multiple users, and 90% of our tickets comes from this channel, we created this to share same whatsapp account cross our team. + +## How it works? + +On every new message received in an associated WhatsApp, a new Ticket is created. Then, this ticket can be reached in a _queue_ on _Tickets_ page, where you can assign ticket to your yourself by _aceppting_ it, respond ticket message and eventually _resolve_ it. + +Subsequent messages from same contact will be related to first **open/pending** ticket found. + +If a contact sent a new message in less than 2 hours interval, and there is no ticket from this contact with **pending/open** status, the newest **closed** ticket will be reopen, instead of creating a new one. + +## Screenshots + +![](https://github.com/canove/whaticket/raw/master/images/whaticket-queues.gif) + + +## 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) ✅ + +## Installation and Usage (Linux Ubuntu - Development) + +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 +``` + +Install puppeteer dependencies: + +```bash +sudo apt-get install -y libxshmfence-dev 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 +``` + +Clone this repo + +```bash +git clone https://github.com/canove/whaticket/ whaticket +``` + +Go to backend folder and create .env file: + +```bash +cp .env.example .env +nano .env +``` + +Fill `.env` file with environment variables: + +```bash +NODE_ENV=DEVELOPMENT #it helps on debugging +BACKEND_URL=http://localhost +FRONTEND_URL=https://localhost:3000 +PROXY_PORT=8080 +PORT=8080 + +DB_HOST= #DB host IP, usually localhost +DB_DIALECT= +DB_USER= +DB_PASS= +DB_NAME= + +JWT_SECRET=3123123213123 +JWT_REFRESH_SECRET=75756756756 +``` + +Install backend dependencies, build app, run migrations and seeds: + +```bash +npm install +npm run build +npx sequelize db:migrate +npx sequelize db:seed:all +``` + +Start backend: + +```bash +npm start +``` + +Open a second terminal, go to frontend folder and create .env file: + +```bash +nano .env +REACT_APP_BACKEND_URL = http://localhost:8080/ # Your previous configured backend app URL. +``` + +Start frontend app: + +```bash +npm start +``` + +- Go to http://your_server_ip:3000/signup +- Create an user and login with it. +- On the sidebard, go to _Connections_ page 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) + +All instructions below assumes you are NOT running as root, since it will give an error in puppeteer. So let's start creating a new user and granting sudo privileges to it: + +```bash +adduser deploy +usermod -aG sudo deploy +``` + +Now we can login with this new user: + +```bash +su deploy +``` + +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. + +Update all system packages: + +```bash +sudo apt update && sudo apt upgrade +``` + +Install node and confirm node command is available: + +```bash +curl -fsSL https://deb.nodesource.com/setup_14.x | sudo -E bash - +sudo apt-get install -y nodejs +node -v +npm -v +``` + +Install docker and add you user to docker group: + +```bash +sudo apt install apt-transport-https ca-certificates curl software-properties-common +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - +sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable" +sudo apt update +sudo apt install docker-ce +sudo systemctl status docker +sudo usermod -aG docker ${USER} +su - ${USER} +``` + +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 +``` + +Create backend .env file and fill with details: + +```bash +cp whaticket/backend/.env.example whaticket/backend/.env +nano whaticket/backend/.env +``` + +```bash +NODE_ENV= +BACKEND_URL=https://api.mydomain.com #USE HTTPS HERE, WE WILL ADD SSL LATTER +FRONTEND_URL=https://myapp.mydomain.com #USE HTTPS HERE, WE WILL ADD SSL LATTER, CORS RELATED! +PROXY_PORT=443 #USE NGINX REVERSE PROXY PORT HERE, WE WILL CONFIGURE IT LATTER +PORT=8080 + +DB_HOST=localhost +DB_DIALECT= +DB_USER= +DB_PASS= +DB_NAME= + +JWT_SECRET=3123123213123 +JWT_REFRESH_SECRET=75756756756 +``` + +Install puppeteer dependencies: + +```bash +sudo apt-get install -y libxshmfence-dev 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, build app, run migrations and seeds: + +```bash +cd whaticket/backend +npm install +npm run build +npx sequelize db:migrate +npx sequelize db:seed:all +``` + +Start it with `npm start`, you should see: `Server started on port...` on console. Hit `CTRL + C` to exit. + +Install pm2 **with sudo**, and start backend with it: + +```bash +sudo npm install -g pm2 +pm2 start dist/server.js --name whaticket-backend +``` + +Make pm2 auto start afeter reboot: + +```bash +pm2 startup ubuntu -u `YOUR_USERNAME` +``` + +Copy the last line outputed from previus command and run it, its something like: + +```bash +sudo env PATH=\$PATH:/usr/bin pm2 startup ubuntu -u YOUR_USERNAME --hp /home/YOUR_USERNAM +``` + +Go to frontend folder and install dependencies: + +```bash +cd ../frontend +npm install +``` + +Edit .env file and fill it with your backend address, it should look like this: + +```bash +REACT_APP_BACKEND_URL = https://api.mydomain.com/ +``` + +Build frontend app: + +```bash +npm run build +``` + +Start frontend with pm2, and save pm2 process list to start automatically after reboot: + +```bash +pm2 start server.js --name whaticket-frontend +pm2 save +``` + +To check if it's running, run `pm2 list`, it should look like: + +```bash +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: + +```bash +sudo apt install nginx +``` + +Remove nginx default site: + +```bash +sudo rm /etc/nginx/sites-enabled/default +``` + +Create a new nginx site to frontend app: + +```bash +sudo nano /etc/nginx/sites-available/whaticket-frontend +``` + +Edit and fill it with this information, changing `server_name` to yours equivalent to `myapp.mydomain.com`: + +```bash +server { + server_name myapp.mydomain.com; + + location / { + proxy_pass http://127.0.0.1:3333; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } +} +``` + +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 +sudo nano /etc/nginx/sites-available/whaticket-backend +``` + +```bash +server { + server_name api.mydomain.com; + + location / { + proxy_pass http://127.0.0.1:8080; + ...... +} +``` + +Create a symbolic links to enalbe nginx sites: + +```bash +sudo ln -s /etc/nginx/sites-available/whaticket-frontend /etc/nginx/sites-enabled +sudo ln -s /etc/nginx/sites-available/whaticket-backend /etc/nginx/sites-enabled +``` + +By default, nginx limit body size to 1MB, what isn't enough to some media uploads. Lets change it to 20MB adding a new line to config file: + +```bash +sudo nano /etc/nginx/nginx.conf +... +http { + ... + client_max_body_size 20M; # HANDLE BIGGER UPLOADS +} +``` + +Test nginx configuration and restart server: + +```bash +sudo nginx -t +sudo service nginx restart +``` + +Now, enable SSL (https) on your sites to use all app features like notifications and sending audio messages. A easy way to this is using Certbot: + +Install certbot: + +```bash +sudo add-apt-repository ppa:certbot/certbot +sudo apt update +sudo apt install python-certbot-nginx +``` + +Enable SSL on nginx (Fill / Accept all information asked): + +```bash +sudo certbot --nginx +``` + +## Access Data + +User: admin@whaticket.com +Password: admin + +## Upgrading + +WhaTicket is a working in progress and we are adding new features frequently. To update your old installation and get all the new features, you can use a bash script like this: + +**Note**: Always check the .env.example and adjust your .env file before upgrading, since some new variable may be added. + +```bash +nano updateWhaticket +``` + +```bash +#!/bin/bash +echo "Updating Whaticket, please wait." + +cd ~ +cd whaticket +git pull +cd backend +npm install +rm -rf dist +npm run build +npx sequelize db:migrate +npx sequelize db:seed +cd ../frontend +npm install +rm -rf build +npm run build +pm2 restart all + +echo "Update finished. Enjoy!" +``` + +Make it executable and run it: + +```bash +chmod +x updateWhaticket +./updateWhaticket +``` + +## Contributing + +This project helps you and you want to help keep it going? Buy me a coffee: + +Buy Me A Coffee + +Para doações em BRL, utilize o Paypal: + +[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate?business=VWW3BHW4AWHUY&item_name=Desenvolvimento+de+Software¤cy_code=BRL) + +Any help and suggestions will be apreciated. + +## Disclaimer + +I just started leaning Javascript a few months ago and this is my first project. It may have security issues and many bugs. I recommend using it only on local network. + +This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with WhatsApp or any of its subsidiaries or its affiliates. The official WhatsApp website can be found at https://whatsapp.com. "WhatsApp" as well as related names, marks, emblems and images are registered trademarks of their respective owners. diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..11695db --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +end_of_line = lf +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d3ad7a6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +NODE_ENV= +BACKEND_URL=http://localhost +FRONTEND_URL=http://localhost:3000 +PROXY_PORT=8080 +PORT=8080 + +DB_DIALECT= +DB_HOST= +DB_USER= +DB_PASS= +DB_NAME= + +JWT_SECRET= +JWT_REFRESH_SECRET= diff --git a/backend/.eslintignore b/backend/.eslintignore new file mode 100644 index 0000000..77b9a34 --- /dev/null +++ b/backend/.eslintignore @@ -0,0 +1,3 @@ +/*.js +node_modules +dist diff --git a/backend/.eslintrc.json b/backend/.eslintrc.json new file mode 100644 index 0000000..aa015fa --- /dev/null +++ b/backend/.eslintrc.json @@ -0,0 +1,49 @@ +{ + "env": { + "es2021": true, + "node": true, + "jest": true + }, + "extends": [ + "airbnb-base", + "plugin:@typescript-eslint/recommended", + "prettier/@typescript-eslint", + "plugin:prettier/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "prettier"], + "rules": { + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "_" } + ], + "import/prefer-default-export": "off", + "no-console": "off", + "no-param-reassign": "off", + "prettier/prettier": "error", + "import/extensions": [ + "error", + "ignorePackages", + { + "ts": "never" + } + ], + "quotes": [ + 1, + "double", + { + "avoidEscape": true + } + ] + }, + "settings": { + "import/resolver": { + "typescript": {} + } + } +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..1c092bc --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,16 @@ +node_modules +public/* +dist +!public/.gitkeep +.env +.env.test + +package-lock.json +yarn.lock +yarn-error.log + +/src/config/sentry.js + +# Ignore test-related files +/coverage.data +/coverage/ diff --git a/backend/.sequelizerc b/backend/.sequelizerc new file mode 100644 index 0000000..264f851 --- /dev/null +++ b/backend/.sequelizerc @@ -0,0 +1,8 @@ +const { resolve } = require("path"); + +module.exports = { + "config": resolve(__dirname, "dist", "config", "database.js"), + "modules-path": resolve(__dirname, "dist", "models"), + "migrations-path": resolve(__dirname, "dist", "database", "migrations"), + "seeders-path": resolve(__dirname, "dist", "database", "seeds") +}; diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..76c1b57 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,186 @@ +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/en/configuration.html + */ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + bail: 1, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/tmp/jest_rs", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ["/src/services/**/*.ts"], + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + coverageReporters: ["text", "lcov"], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + preset: "ts-jest", + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: ["**/__tests__/**/*.spec.ts"] + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..ba4ac10 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,76 @@ +{ + "name": "backend", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "start": "nodemon dist/server.js", + "dev:server": "ts-node-dev --respawn --transpile-only --ignore node_modules src/server.ts", + "pretest": "NODE_ENV=test sequelize db:migrate && NODE_ENV=test sequelize db:seed:all", + "test": "NODE_ENV=test jest", + "posttest": "NODE_ENV=test sequelize db:migrate:undo:all" + }, + "author": "", + "license": "MIT", + "dependencies": { + "@sentry/node": "^5.29.2", + "@types/pino": "^6.3.4", + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.5", + "cors": "^2.8.5", + "date-fns": "^2.16.1", + "dotenv": "^8.2.0", + "express": "^4.17.1", + "express-async-errors": "^3.1.1", + "http-graceful-shutdown": "^2.3.2", + "jsonwebtoken": "^8.5.1", + "multer": "^1.4.2", + "mysql2": "^2.2.5", + "pg": "^8.4.1", + "pino": "^6.9.0", + "pino-pretty": "^4.3.0", + "qrcode-terminal": "^0.12.0", + "reflect-metadata": "^0.1.13", + "sequelize": "^5.22.3", + "sequelize-cli": "^5.5.1", + "sequelize-typescript": "^1.1.0", + "socket.io": "^3.0.5", + "whatsapp-web.js": "^1.15.3", + "yup": "^0.32.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.2", + "@types/bluebird": "^3.5.32", + "@types/cookie-parser": "^1.4.2", + "@types/cors": "^2.8.7", + "@types/express": "^4.17.13", + "@types/factory-girl": "^5.0.2", + "@types/faker": "^5.1.3", + "@types/jest": "^26.0.15", + "@types/jsonwebtoken": "^8.5.0", + "@types/multer": "^1.4.4", + "@types/node": "^14.11.8", + "@types/supertest": "^2.0.10", + "@types/validator": "^13.1.0", + "@types/yup": "^0.29.8", + "@typescript-eslint/eslint-plugin": "^4.4.0", + "@typescript-eslint/parser": "^4.4.0", + "eslint": "^7.10.0", + "eslint-config-airbnb-base": "^14.2.0", + "eslint-config-prettier": "^6.12.0", + "eslint-import-resolver-typescript": "^2.3.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-prettier": "^3.1.4", + "factory-girl": "^5.0.4", + "faker": "^5.1.0", + "jest": "^26.6.0", + "nodemon": "^2.0.4", + "prettier": "^2.1.2", + "supertest": "^5.0.0", + "ts-jest": "^26.4.1", + "ts-node-dev": "^1.0.0-pre.63", + "typescript": "4.0.3" + } +} diff --git a/backend/prettier.config.js b/backend/prettier.config.js new file mode 100644 index 0000000..2821955 --- /dev/null +++ b/backend/prettier.config.js @@ -0,0 +1,5 @@ +module.exports = { + singleQuote: false, + trailingComma: "none", + arrowParens: "avoid" +}; diff --git a/backend/public/.gitkeep b/backend/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/@types/express.d.ts b/backend/src/@types/express.d.ts new file mode 100644 index 0000000..3315ef3 --- /dev/null +++ b/backend/src/@types/express.d.ts @@ -0,0 +1,5 @@ +declare namespace Express { + export interface Request { + user: { id: string; profile: string }; + } +} diff --git a/backend/src/@types/qrcode-terminal.d.ts b/backend/src/@types/qrcode-terminal.d.ts new file mode 100644 index 0000000..3b59fed --- /dev/null +++ b/backend/src/@types/qrcode-terminal.d.ts @@ -0,0 +1 @@ +declare module "qrcode-terminal"; diff --git a/backend/src/__tests__/unit/User/AuthUserService.spec.ts b/backend/src/__tests__/unit/User/AuthUserService.spec.ts new file mode 100644 index 0000000..c7163ca --- /dev/null +++ b/backend/src/__tests__/unit/User/AuthUserService.spec.ts @@ -0,0 +1,69 @@ +import faker from "faker"; +import AppError from "../../../errors/AppError"; +import AuthUserService from "../../../services/UserServices/AuthUserService"; +import CreateUserService from "../../../services/UserServices/CreateUserService"; +import { disconnect, truncate } from "../../utils/database"; + +describe("Auth", () => { + beforeEach(async () => { + await truncate(); + }); + + afterEach(async () => { + await truncate(); + }); + + afterAll(async () => { + await disconnect(); + }); + + it("should be able to login with an existing user", async () => { + const password = faker.internet.password(); + const email = faker.internet.email(); + + await CreateUserService({ + name: faker.name.findName(), + email, + password + }); + + const response = await AuthUserService({ + email, + password + }); + + expect(response).toHaveProperty("token"); + }); + + it("should not be able to login with not registered email", async () => { + try { + await AuthUserService({ + email: faker.internet.email(), + password: faker.internet.password() + }); + } catch (err) { + expect(err).toBeInstanceOf(AppError); + expect(err.statusCode).toBe(401); + expect(err.message).toBe("ERR_INVALID_CREDENTIALS"); + } + }); + + it("should not be able to login with incorret password", async () => { + await CreateUserService({ + name: faker.name.findName(), + email: "mail@test.com", + password: faker.internet.password() + }); + + try { + await AuthUserService({ + email: "mail@test.com", + password: faker.internet.password() + }); + } catch (err) { + expect(err).toBeInstanceOf(AppError); + expect(err.statusCode).toBe(401); + expect(err.message).toBe("ERR_INVALID_CREDENTIALS"); + } + }); +}); diff --git a/backend/src/__tests__/unit/User/CreateUserService.spec.ts b/backend/src/__tests__/unit/User/CreateUserService.spec.ts new file mode 100644 index 0000000..d6721bc --- /dev/null +++ b/backend/src/__tests__/unit/User/CreateUserService.spec.ts @@ -0,0 +1,47 @@ +import faker from "faker"; +import AppError from "../../../errors/AppError"; +import CreateUserService from "../../../services/UserServices/CreateUserService"; +import { disconnect, truncate } from "../../utils/database"; + +describe("User", () => { + beforeEach(async () => { + await truncate(); + }); + + afterEach(async () => { + await truncate(); + }); + + afterAll(async () => { + await disconnect(); + }); + + it("should be able to create a new user", async () => { + const user = await CreateUserService({ + name: faker.name.findName(), + email: faker.internet.email(), + password: faker.internet.password() + }); + + expect(user).toHaveProperty("id"); + }); + + it("should not be able to create a user with duplicated email", async () => { + await CreateUserService({ + name: faker.name.findName(), + email: "teste@sameemail.com", + password: faker.internet.password() + }); + + try { + await CreateUserService({ + name: faker.name.findName(), + email: "teste@sameemail.com", + password: faker.internet.password() + }); + } catch (err) { + expect(err).toBeInstanceOf(AppError); + expect(err.statusCode).toBe(400); + } + }); +}); diff --git a/backend/src/__tests__/unit/User/DeleteUserService.spec.ts b/backend/src/__tests__/unit/User/DeleteUserService.spec.ts new file mode 100644 index 0000000..3fc8372 --- /dev/null +++ b/backend/src/__tests__/unit/User/DeleteUserService.spec.ts @@ -0,0 +1,35 @@ +import faker from "faker"; +import AppError from "../../../errors/AppError"; +import CreateUserService from "../../../services/UserServices/CreateUserService"; +import DeleteUserService from "../../../services/UserServices/DeleteUserService"; +import { disconnect, truncate } from "../../utils/database"; + +describe("User", () => { + beforeEach(async () => { + await truncate(); + }); + + afterEach(async () => { + await truncate(); + }); + + afterAll(async () => { + await disconnect(); + }); + + it("should be delete a existing user", async () => { + const { id } = await CreateUserService({ + name: faker.name.findName(), + email: faker.internet.email(), + password: faker.internet.password() + }); + + expect(DeleteUserService(id)).resolves.not.toThrow(); + }); + + it("to throw an error if tries to delete a non existing user", async () => { + expect(DeleteUserService(faker.random.number())).rejects.toBeInstanceOf( + AppError + ); + }); +}); diff --git a/backend/src/__tests__/unit/User/ListUserService.spec.ts b/backend/src/__tests__/unit/User/ListUserService.spec.ts new file mode 100644 index 0000000..4fa777f --- /dev/null +++ b/backend/src/__tests__/unit/User/ListUserService.spec.ts @@ -0,0 +1,34 @@ +import faker from "faker"; +import User from "../../../models/User"; +import CreateUserService from "../../../services/UserServices/CreateUserService"; +import ListUsersService from "../../../services/UserServices/ListUsersService"; +import { disconnect, truncate } from "../../utils/database"; + +describe("User", () => { + beforeEach(async () => { + await truncate(); + }); + + afterEach(async () => { + await truncate(); + }); + + afterAll(async () => { + await disconnect(); + }); + + it("should be able to list users", async () => { + await CreateUserService({ + name: faker.name.findName(), + email: faker.internet.email(), + password: faker.internet.password() + }); + + const response = await ListUsersService({ + pageNumber: 1 + }); + + expect(response).toHaveProperty("users"); + expect(response.users[0]).toBeInstanceOf(User); + }); +}); diff --git a/backend/src/__tests__/unit/User/ShowUserService.spec.ts b/backend/src/__tests__/unit/User/ShowUserService.spec.ts new file mode 100644 index 0000000..2883180 --- /dev/null +++ b/backend/src/__tests__/unit/User/ShowUserService.spec.ts @@ -0,0 +1,39 @@ +import faker from "faker"; +import AppError from "../../../errors/AppError"; +import User from "../../../models/User"; +import CreateUserService from "../../../services/UserServices/CreateUserService"; +import ShowUserService from "../../../services/UserServices/ShowUserService"; +import { disconnect, truncate } from "../../utils/database"; + +describe("User", () => { + beforeEach(async () => { + await truncate(); + }); + + afterEach(async () => { + await truncate(); + }); + + afterAll(async () => { + await disconnect(); + }); + + it("should be able to find a user", async () => { + const newUser = await CreateUserService({ + name: faker.name.findName(), + email: faker.internet.email(), + password: faker.internet.password() + }); + + const user = await ShowUserService(newUser.id); + + expect(user).toHaveProperty("id"); + expect(user).toBeInstanceOf(User); + }); + + it("should not be able to find a inexisting user", async () => { + expect(ShowUserService(faker.random.number())).rejects.toBeInstanceOf( + AppError + ); + }); +}); diff --git a/backend/src/__tests__/unit/User/UpdateUserService.spec.ts b/backend/src/__tests__/unit/User/UpdateUserService.spec.ts new file mode 100644 index 0000000..496926d --- /dev/null +++ b/backend/src/__tests__/unit/User/UpdateUserService.spec.ts @@ -0,0 +1,68 @@ +import faker from "faker"; +import AppError from "../../../errors/AppError"; +import CreateUserService from "../../../services/UserServices/CreateUserService"; +import UpdateUserService from "../../../services/UserServices/UpdateUserService"; +import { disconnect, truncate } from "../../utils/database"; + +describe("User", () => { + beforeEach(async () => { + await truncate(); + }); + + afterEach(async () => { + await truncate(); + }); + + afterAll(async () => { + await disconnect(); + }); + + it("should be able to find a user", async () => { + const newUser = await CreateUserService({ + name: faker.name.findName(), + email: faker.internet.email(), + password: faker.internet.password() + }); + + const updatedUser = await UpdateUserService({ + userId: newUser.id, + userData: { + name: "New name", + email: "newmail@email.com" + } + }); + + expect(updatedUser).toHaveProperty("name", "New name"); + expect(updatedUser).toHaveProperty("email", "newmail@email.com"); + }); + + it("should not be able to updated a inexisting user", async () => { + const userId = faker.random.number(); + const userData = { + name: faker.name.findName(), + email: faker.internet.email() + }; + + expect(UpdateUserService({ userId, userData })).rejects.toBeInstanceOf( + AppError + ); + }); + + it("should not be able to updated an user with invalid data", async () => { + const newUser = await CreateUserService({ + name: faker.name.findName(), + email: faker.internet.email(), + password: faker.internet.password() + }); + + const userId = newUser.id; + const userData = { + name: faker.name.findName(), + email: "test.worgn.email" + }; + + expect(UpdateUserService({ userId, userData })).rejects.toBeInstanceOf( + AppError + ); + }); +}); diff --git a/backend/src/__tests__/utils/database.ts b/backend/src/__tests__/utils/database.ts new file mode 100644 index 0000000..34645fa --- /dev/null +++ b/backend/src/__tests__/utils/database.ts @@ -0,0 +1,11 @@ +import database from "../../database"; + +const truncate = async (): Promise => { + await database.truncate({ force: true, cascade: true }); +}; + +const disconnect = async (): Promise => { + return database.connectionManager.close(); +}; + +export { truncate, disconnect }; diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..dd8155b --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,43 @@ +import "./bootstrap"; +import "reflect-metadata"; +import "express-async-errors"; +import express, { Request, Response, NextFunction } from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import * as Sentry from "@sentry/node"; + +import "./database"; +import uploadConfig from "./config/upload"; +import AppError from "./errors/AppError"; +import routes from "./routes"; +import { logger } from "./utils/logger"; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const app = express(); + +app.use( + cors({ + credentials: true, + origin: process.env.FRONTEND_URL + }) +); +app.use(cookieParser()); +app.use(express.json()); +app.use(Sentry.Handlers.requestHandler()); +app.use("/public", express.static(uploadConfig.directory)); +app.use(routes); + +app.use(Sentry.Handlers.errorHandler()); + +app.use(async (err: Error, req: Request, res: Response, _: NextFunction) => { + if (err instanceof AppError) { + logger.warn(err); + return res.status(err.statusCode).json({ error: err.message }); + } + + logger.error(err); + return res.status(500).json({ error: "Internal server error" }); +}); + +export default app; diff --git a/backend/src/bootstrap.ts b/backend/src/bootstrap.ts new file mode 100644 index 0000000..03fffb5 --- /dev/null +++ b/backend/src/bootstrap.ts @@ -0,0 +1,5 @@ +import dotenv from "dotenv"; + +dotenv.config({ + path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" +}); diff --git a/backend/src/config/auth.ts b/backend/src/config/auth.ts new file mode 100644 index 0000000..6f8c5fd --- /dev/null +++ b/backend/src/config/auth.ts @@ -0,0 +1,6 @@ +export default { + secret: process.env.JWT_SECRET || "mysecret", + expiresIn: "15m", + refreshSecret: process.env.JWT_REFRESH_SECRET || "myanothersecret", + refreshExpiresIn: "7d" +}; diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts new file mode 100644 index 0000000..d955374 --- /dev/null +++ b/backend/src/config/database.ts @@ -0,0 +1,15 @@ +require("../bootstrap"); + +module.exports = { + define: { + charset: "utf8mb4", + collate: "utf8mb4_bin" + }, + dialect: process.env.DB_DIALECT || "mysql", + timezone: "-03:00", + host: process.env.DB_HOST, + database: process.env.DB_NAME, + username: process.env.DB_USER, + password: process.env.DB_PASS, + logging: false +}; diff --git a/backend/src/config/upload.ts b/backend/src/config/upload.ts new file mode 100644 index 0000000..24fb8f8 --- /dev/null +++ b/backend/src/config/upload.ts @@ -0,0 +1,16 @@ +import path from "path"; +import multer from "multer"; + +const publicFolder = path.resolve(__dirname, "..", "..", "public"); +export default { + directory: publicFolder, + + storage: multer.diskStorage({ + destination: publicFolder, + filename(req, file, cb) { + const fileName = new Date().getTime() + path.extname(file.originalname); + + return cb(null, fileName); + } + }) +}; diff --git a/backend/src/controllers/ContactController.ts b/backend/src/controllers/ContactController.ts new file mode 100644 index 0000000..536ba3a --- /dev/null +++ b/backend/src/controllers/ContactController.ts @@ -0,0 +1,145 @@ +import * as Yup from "yup"; +import { Request, Response } from "express"; +import { getIO } from "../libs/socket"; + +import ListContactsService from "../services/ContactServices/ListContactsService"; +import CreateContactService from "../services/ContactServices/CreateContactService"; +import ShowContactService from "../services/ContactServices/ShowContactService"; +import UpdateContactService from "../services/ContactServices/UpdateContactService"; +import DeleteContactService from "../services/ContactServices/DeleteContactService"; + +import CheckContactNumber from "../services/WbotServices/CheckNumber" +import CheckIsValidContact from "../services/WbotServices/CheckIsValidContact"; +import GetProfilePicUrl from "../services/WbotServices/GetProfilePicUrl"; +import AppError from "../errors/AppError"; + +type IndexQuery = { + searchParam: string; + pageNumber: string; +}; + +interface ExtraInfo { + name: string; + value: string; +} +interface ContactData { + name: string; + number: string; + email?: string; + extraInfo?: ExtraInfo[]; +} + +export const index = async (req: Request, res: Response): Promise => { + const { searchParam, pageNumber } = req.query as IndexQuery; + + const { contacts, count, hasMore } = await ListContactsService({ + searchParam, + pageNumber + }); + + return res.json({ contacts, count, hasMore }); +}; + +export const store = async (req: Request, res: Response): Promise => { + const newContact: ContactData = req.body; + newContact.number = newContact.number.replace("-", "").replace(" ", ""); + + const schema = Yup.object().shape({ + name: Yup.string().required(), + number: Yup.string() + .required() + .matches(/^\d+$/, "Invalid number format. Only numbers is allowed.") + }); + + try { + await schema.validate(newContact); + } catch (err) { + throw new AppError(err.message); + } + + await CheckIsValidContact(newContact.number); + const validNumber : any = await CheckContactNumber(newContact.number) + + const profilePicUrl = await GetProfilePicUrl(validNumber); + + let name = newContact.name + let number = validNumber + let email = newContact.email + let extraInfo = newContact.extraInfo + + const contact = await CreateContactService({ + name, + number, + email, + extraInfo, + profilePicUrl + }); + + const io = getIO(); + io.emit("contact", { + action: "create", + contact + }); + + return res.status(200).json(contact); +}; + +export const show = async (req: Request, res: Response): Promise => { + const { contactId } = req.params; + + const contact = await ShowContactService(contactId); + + return res.status(200).json(contact); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + const contactData: ContactData = req.body; + + const schema = Yup.object().shape({ + name: Yup.string(), + number: Yup.string().matches( + /^\d+$/, + "Invalid number format. Only numbers is allowed." + ) + }); + + try { + await schema.validate(contactData); + } catch (err) { + throw new AppError(err.message); + } + + await CheckIsValidContact(contactData.number); + + const { contactId } = req.params; + + const contact = await UpdateContactService({ contactData, contactId }); + + const io = getIO(); + io.emit("contact", { + action: "update", + contact + }); + + return res.status(200).json(contact); +}; + +export const remove = async ( + req: Request, + res: Response +): Promise => { + const { contactId } = req.params; + + await DeleteContactService(contactId); + + const io = getIO(); + io.emit("contact", { + action: "delete", + contactId + }); + + return res.status(200).json({ message: "Contact deleted" }); +}; diff --git a/backend/src/controllers/ImportPhoneContactsController.ts b/backend/src/controllers/ImportPhoneContactsController.ts new file mode 100644 index 0000000..01f1cfc --- /dev/null +++ b/backend/src/controllers/ImportPhoneContactsController.ts @@ -0,0 +1,8 @@ +import { Request, Response } from "express"; +import ImportContactsService from "../services/WbotServices/ImportContactsService"; + +export const store = async (req: Request, res: Response): Promise => { + await ImportContactsService(); + + return res.status(200).json({ message: "contacts imported" }); +}; diff --git a/backend/src/controllers/MessageController.ts b/backend/src/controllers/MessageController.ts new file mode 100644 index 0000000..21f90aa --- /dev/null +++ b/backend/src/controllers/MessageController.ts @@ -0,0 +1,75 @@ +import { Request, Response } from "express"; + +import SetTicketMessagesAsRead from "../helpers/SetTicketMessagesAsRead"; +import { getIO } from "../libs/socket"; +import Message from "../models/Message"; + +import ListMessagesService from "../services/MessageServices/ListMessagesService"; +import ShowTicketService from "../services/TicketServices/ShowTicketService"; +import DeleteWhatsAppMessage from "../services/WbotServices/DeleteWhatsAppMessage"; +import SendWhatsAppMedia from "../services/WbotServices/SendWhatsAppMedia"; +import SendWhatsAppMessage from "../services/WbotServices/SendWhatsAppMessage"; + +type IndexQuery = { + pageNumber: string; +}; + +type MessageData = { + body: string; + fromMe: boolean; + read: boolean; + quotedMsg?: Message; +}; + +export const index = async (req: Request, res: Response): Promise => { + const { ticketId } = req.params; + const { pageNumber } = req.query as IndexQuery; + + const { count, messages, ticket, hasMore } = await ListMessagesService({ + pageNumber, + ticketId + }); + + SetTicketMessagesAsRead(ticket); + + return res.json({ count, messages, ticket, hasMore }); +}; + +export const store = async (req: Request, res: Response): Promise => { + const { ticketId } = req.params; + const { body, quotedMsg }: MessageData = req.body; + const medias = req.files as Express.Multer.File[]; + + const ticket = await ShowTicketService(ticketId); + + SetTicketMessagesAsRead(ticket); + + if (medias) { + await Promise.all( + medias.map(async (media: Express.Multer.File) => { + await SendWhatsAppMedia({ media, ticket }); + }) + ); + } else { + await SendWhatsAppMessage({ body, ticket, quotedMsg }); + } + + return res.send(); +}; + +export const remove = async ( + req: Request, + res: Response +): Promise => { + const { messageId } = req.params; + + const message = await DeleteWhatsAppMessage(messageId); + + const io = getIO(); + io.to(message.ticketId.toString()).emit("appMessage", { + action: "update", + message + }); + + return res.send(); +}; diff --git a/backend/src/controllers/QueueController.ts b/backend/src/controllers/QueueController.ts new file mode 100644 index 0000000..0ffa66c --- /dev/null +++ b/backend/src/controllers/QueueController.ts @@ -0,0 +1,69 @@ +import { Request, Response } from "express"; +import { getIO } from "../libs/socket"; +import CreateQueueService from "../services/QueueService/CreateQueueService"; +import DeleteQueueService from "../services/QueueService/DeleteQueueService"; +import ListQueuesService from "../services/QueueService/ListQueuesService"; +import ShowQueueService from "../services/QueueService/ShowQueueService"; +import UpdateQueueService from "../services/QueueService/UpdateQueueService"; + +export const index = async (req: Request, res: Response): Promise => { + const queues = await ListQueuesService(); + + return res.status(200).json(queues); +}; + +export const store = async (req: Request, res: Response): Promise => { + const { name, color, greetingMessage } = req.body; + + const queue = await CreateQueueService({ name, color, greetingMessage }); + + const io = getIO(); + io.emit("queue", { + action: "update", + queue + }); + + return res.status(200).json(queue); +}; + +export const show = async (req: Request, res: Response): Promise => { + const { queueId } = req.params; + + const queue = await ShowQueueService(queueId); + + return res.status(200).json(queue); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + const { queueId } = req.params; + + const queue = await UpdateQueueService(queueId, req.body); + + const io = getIO(); + io.emit("queue", { + action: "update", + queue + }); + + return res.status(201).json(queue); +}; + +export const remove = async ( + req: Request, + res: Response +): Promise => { + const { queueId } = req.params; + + await DeleteQueueService(queueId); + + const io = getIO(); + io.emit("queue", { + action: "delete", + queueId: +queueId + }); + + return res.status(200).send(); +}; diff --git a/backend/src/controllers/QuickAnswerController.ts b/backend/src/controllers/QuickAnswerController.ts new file mode 100644 index 0000000..3828e91 --- /dev/null +++ b/backend/src/controllers/QuickAnswerController.ts @@ -0,0 +1,117 @@ +import * as Yup from "yup"; +import { Request, Response } from "express"; +import { getIO } from "../libs/socket"; + +import ListQuickAnswerService from "../services/QuickAnswerService/ListQuickAnswerService"; +import CreateQuickAnswerService from "../services/QuickAnswerService/CreateQuickAnswerService"; +import ShowQuickAnswerService from "../services/QuickAnswerService/ShowQuickAnswerService"; +import UpdateQuickAnswerService from "../services/QuickAnswerService/UpdateQuickAnswerService"; +import DeleteQuickAnswerService from "../services/QuickAnswerService/DeleteQuickAnswerService"; + +import AppError from "../errors/AppError"; + +type IndexQuery = { + searchParam: string; + pageNumber: string; +}; + +interface QuickAnswerData { + shortcut: string; + message: string; +} + +export const index = async (req: Request, res: Response): Promise => { + const { searchParam, pageNumber } = req.query as IndexQuery; + + const { quickAnswers, count, hasMore } = await ListQuickAnswerService({ + searchParam, + pageNumber + }); + + return res.json({ quickAnswers, count, hasMore }); +}; + +export const store = async (req: Request, res: Response): Promise => { + const newQuickAnswer: QuickAnswerData = req.body; + + const QuickAnswerSchema = Yup.object().shape({ + shortcut: Yup.string().required(), + message: Yup.string().required() + }); + + try { + await QuickAnswerSchema.validate(newQuickAnswer); + } catch (err) { + throw new AppError(err.message); + } + + const quickAnswer = await CreateQuickAnswerService({ + ...newQuickAnswer + }); + + const io = getIO(); + io.emit("quickAnswer", { + action: "create", + quickAnswer + }); + + return res.status(200).json(quickAnswer); +}; + +export const show = async (req: Request, res: Response): Promise => { + const { quickAnswerId } = req.params; + + const quickAnswer = await ShowQuickAnswerService(quickAnswerId); + + return res.status(200).json(quickAnswer); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + const quickAnswerData: QuickAnswerData = req.body; + + const schema = Yup.object().shape({ + shortcut: Yup.string(), + message: Yup.string() + }); + + try { + await schema.validate(quickAnswerData); + } catch (err) { + throw new AppError(err.message); + } + + const { quickAnswerId } = req.params; + + const quickAnswer = await UpdateQuickAnswerService({ + quickAnswerData, + quickAnswerId + }); + + const io = getIO(); + io.emit("quickAnswer", { + action: "update", + quickAnswer + }); + + return res.status(200).json(quickAnswer); +}; + +export const remove = async ( + req: Request, + res: Response +): Promise => { + const { quickAnswerId } = req.params; + + await DeleteQuickAnswerService(quickAnswerId); + + const io = getIO(); + io.emit("quickAnswer", { + action: "delete", + quickAnswerId + }); + + return res.status(200).json({ message: "Quick Answer deleted" }); +}; diff --git a/backend/src/controllers/SessionController.ts b/backend/src/controllers/SessionController.ts new file mode 100644 index 0000000..1cb482c --- /dev/null +++ b/backend/src/controllers/SessionController.ts @@ -0,0 +1,51 @@ +import { Request, Response } from "express"; +import AppError from "../errors/AppError"; + +import AuthUserService from "../services/UserServices/AuthUserService"; +import { SendRefreshToken } from "../helpers/SendRefreshToken"; +import { RefreshTokenService } from "../services/AuthServices/RefreshTokenService"; + +export const store = async (req: Request, res: Response): Promise => { + const { email, password } = req.body; + + const { token, serializedUser, refreshToken } = await AuthUserService({ + email, + password + }); + + SendRefreshToken(res, refreshToken); + + return res.status(200).json({ + token, + user: serializedUser + }); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + const token: string = req.cookies.jrt; + + if (!token) { + throw new AppError("ERR_SESSION_EXPIRED", 401); + } + + const { user, newToken, refreshToken } = await RefreshTokenService( + res, + token + ); + + SendRefreshToken(res, refreshToken); + + return res.json({ token: newToken, user }); +}; + +export const remove = async ( + req: Request, + res: Response +): Promise => { + res.clearCookie("jrt"); + + return res.send(); +}; diff --git a/backend/src/controllers/SettingController.ts b/backend/src/controllers/SettingController.ts new file mode 100644 index 0000000..ff9c01f --- /dev/null +++ b/backend/src/controllers/SettingController.ts @@ -0,0 +1,41 @@ +import { Request, Response } from "express"; + +import { getIO } from "../libs/socket"; +import AppError from "../errors/AppError"; + +import UpdateSettingService from "../services/SettingServices/UpdateSettingService"; +import ListSettingsService from "../services/SettingServices/ListSettingsService"; + +export const index = async (req: Request, res: Response): Promise => { + if (req.user.profile !== "admin") { + throw new AppError("ERR_NO_PERMISSION", 403); + } + + const settings = await ListSettingsService(); + + return res.status(200).json(settings); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + if (req.user.profile !== "admin") { + throw new AppError("ERR_NO_PERMISSION", 403); + } + const { settingKey: key } = req.params; + const { value } = req.body; + + const setting = await UpdateSettingService({ + key, + value + }); + + const io = getIO(); + io.emit("settings", { + action: "update", + setting + }); + + return res.status(200).json(setting); +}; diff --git a/backend/src/controllers/TicketController.ts b/backend/src/controllers/TicketController.ts new file mode 100644 index 0000000..a6af5ef --- /dev/null +++ b/backend/src/controllers/TicketController.ts @@ -0,0 +1,131 @@ +import { Request, Response } from "express"; +import { getIO } from "../libs/socket"; + +import CreateTicketService from "../services/TicketServices/CreateTicketService"; +import DeleteTicketService from "../services/TicketServices/DeleteTicketService"; +import ListTicketsService from "../services/TicketServices/ListTicketsService"; +import ShowTicketService from "../services/TicketServices/ShowTicketService"; +import UpdateTicketService from "../services/TicketServices/UpdateTicketService"; +import SendWhatsAppMessage from "../services/WbotServices/SendWhatsAppMessage"; +import ShowWhatsAppService from "../services/WhatsappService/ShowWhatsAppService"; + +type IndexQuery = { + searchParam: string; + pageNumber: string; + status: string; + date: string; + showAll: string; + withUnreadMessages: string; + queueIds: string; +}; + +interface TicketData { + contactId: number; + status: string; + queueId: number; + userId: number; +} + +export const index = async (req: Request, res: Response): Promise => { + const { + pageNumber, + status, + date, + searchParam, + showAll, + queueIds: queueIdsStringified, + withUnreadMessages + } = req.query as IndexQuery; + + const userId = req.user.id; + + let queueIds: number[] = []; + + if (queueIdsStringified) { + queueIds = JSON.parse(queueIdsStringified); + } + + const { tickets, count, hasMore } = await ListTicketsService({ + searchParam, + pageNumber, + status, + date, + showAll, + userId, + queueIds, + withUnreadMessages + }); + + return res.status(200).json({ tickets, count, hasMore }); +}; + +export const store = async (req: Request, res: Response): Promise => { + const { contactId, status, userId }: TicketData = req.body; + + const ticket = await CreateTicketService({ contactId, status, userId }); + + const io = getIO(); + io.to(ticket.status).emit("ticket", { + action: "update", + ticket + }); + + return res.status(200).json(ticket); +}; + +export const show = async (req: Request, res: Response): Promise => { + const { ticketId } = req.params; + + const contact = await ShowTicketService(ticketId); + + return res.status(200).json(contact); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + const { ticketId } = req.params; + const ticketData: TicketData = req.body; + + const { ticket } = await UpdateTicketService({ + ticketData, + ticketId + }); + + if (ticket.status === "closed") { + const whatsapp = await ShowWhatsAppService(ticket.whatsappId); + + const { farewellMessage } = whatsapp; + + if (farewellMessage) { + await SendWhatsAppMessage({ + body: farewellMessage, + ticket + }); + } + } + + + return res.status(200).json(ticket); +}; + +export const remove = async ( + req: Request, + res: Response +): Promise => { + const { ticketId } = req.params; + + const ticket = await DeleteTicketService(ticketId); + + const io = getIO(); + io.to(ticket.status) + .to(ticketId) + .to("notification") + .emit("ticket", { + action: "delete", + ticketId: +ticketId + }); + + return res.status(200).json({ message: "ticket deleted" }); +}; diff --git a/backend/src/controllers/UserController.ts b/backend/src/controllers/UserController.ts new file mode 100644 index 0000000..06d329d --- /dev/null +++ b/backend/src/controllers/UserController.ts @@ -0,0 +1,107 @@ +import { Request, Response } from "express"; +import { getIO } from "../libs/socket"; + +import CheckSettingsHelper from "../helpers/CheckSettings"; +import AppError from "../errors/AppError"; + +import CreateUserService from "../services/UserServices/CreateUserService"; +import ListUsersService from "../services/UserServices/ListUsersService"; +import UpdateUserService from "../services/UserServices/UpdateUserService"; +import ShowUserService from "../services/UserServices/ShowUserService"; +import DeleteUserService from "../services/UserServices/DeleteUserService"; + +type IndexQuery = { + searchParam: string; + pageNumber: string; +}; + +export const index = async (req: Request, res: Response): Promise => { + const { searchParam, pageNumber } = req.query as IndexQuery; + + const { users, count, hasMore } = await ListUsersService({ + searchParam, + pageNumber + }); + + return res.json({ users, count, hasMore }); +}; + +export const store = async (req: Request, res: Response): Promise => { + const { email, password, name, profile, queueIds } = req.body; + + if ( + req.url === "/signup" && + (await CheckSettingsHelper("userCreation")) === "disabled" + ) { + throw new AppError("ERR_USER_CREATION_DISABLED", 403); + } else if (req.url !== "/signup" && req.user.profile !== "admin") { + throw new AppError("ERR_NO_PERMISSION", 403); + } + + const user = await CreateUserService({ + email, + password, + name, + profile, + queueIds + }); + + const io = getIO(); + io.emit("user", { + action: "create", + user + }); + + return res.status(200).json(user); +}; + +export const show = async (req: Request, res: Response): Promise => { + const { userId } = req.params; + + const user = await ShowUserService(userId); + + return res.status(200).json(user); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + if (req.user.profile !== "admin") { + throw new AppError("ERR_NO_PERMISSION", 403); + } + + const { userId } = req.params; + const userData = req.body; + + const user = await UpdateUserService({ userData, userId }); + + const io = getIO(); + io.emit("user", { + action: "update", + user + }); + + return res.status(200).json(user); +}; + +export const remove = async ( + req: Request, + res: Response +): Promise => { + const { userId } = req.params; + + if (req.user.profile !== "admin") { + throw new AppError("ERR_NO_PERMISSION", 403); + } + + await DeleteUserService(userId); + + const io = getIO(); + io.emit("user", { + action: "delete", + userId + }); + + return res.status(200).json({ message: "User deleted" }); +}; diff --git a/backend/src/controllers/WhatsAppController.ts b/backend/src/controllers/WhatsAppController.ts new file mode 100644 index 0000000..3b4d9fb --- /dev/null +++ b/backend/src/controllers/WhatsAppController.ts @@ -0,0 +1,116 @@ +import { Request, Response } from "express"; +import { getIO } from "../libs/socket"; +import { removeWbot } from "../libs/wbot"; +import { StartWhatsAppSession } from "../services/WbotServices/StartWhatsAppSession"; + +import CreateWhatsAppService from "../services/WhatsappService/CreateWhatsAppService"; +import DeleteWhatsAppService from "../services/WhatsappService/DeleteWhatsAppService"; +import ListWhatsAppsService from "../services/WhatsappService/ListWhatsAppsService"; +import ShowWhatsAppService from "../services/WhatsappService/ShowWhatsAppService"; +import UpdateWhatsAppService from "../services/WhatsappService/UpdateWhatsAppService"; + +interface WhatsappData { + name: string; + queueIds: number[]; + greetingMessage?: string; + farewellMessage?: string; + status?: string; + isDefault?: boolean; +} + +export const index = async (req: Request, res: Response): Promise => { + const whatsapps = await ListWhatsAppsService(); + + return res.status(200).json(whatsapps); +}; + +export const store = async (req: Request, res: Response): Promise => { + const { + name, + status, + isDefault, + greetingMessage, + farewellMessage, + queueIds + }: WhatsappData = req.body; + + const { whatsapp, oldDefaultWhatsapp } = await CreateWhatsAppService({ + name, + status, + isDefault, + greetingMessage, + farewellMessage, + queueIds + }); + + StartWhatsAppSession(whatsapp); + + const io = getIO(); + io.emit("whatsapp", { + action: "update", + whatsapp + }); + + if (oldDefaultWhatsapp) { + io.emit("whatsapp", { + action: "update", + whatsapp: oldDefaultWhatsapp + }); + } + + return res.status(200).json(whatsapp); +}; + +export const show = async (req: Request, res: Response): Promise => { + const { whatsappId } = req.params; + + const whatsapp = await ShowWhatsAppService(whatsappId); + + return res.status(200).json(whatsapp); +}; + +export const update = async ( + req: Request, + res: Response +): Promise => { + const { whatsappId } = req.params; + const whatsappData = req.body; + + const { whatsapp, oldDefaultWhatsapp } = await UpdateWhatsAppService({ + whatsappData, + whatsappId + }); + + const io = getIO(); + io.emit("whatsapp", { + action: "update", + whatsapp + }); + + if (oldDefaultWhatsapp) { + io.emit("whatsapp", { + action: "update", + whatsapp: oldDefaultWhatsapp + }); + } + + return res.status(200).json(whatsapp); +}; + +export const remove = async ( + req: Request, + res: Response +): Promise => { + const { whatsappId } = req.params; + + await DeleteWhatsAppService(whatsappId); + removeWbot(+whatsappId); + + const io = getIO(); + io.emit("whatsapp", { + action: "delete", + whatsappId: +whatsappId + }); + + return res.status(200).json({ message: "Whatsapp deleted." }); +}; diff --git a/backend/src/controllers/WhatsAppSessionController.ts b/backend/src/controllers/WhatsAppSessionController.ts new file mode 100644 index 0000000..045aa20 --- /dev/null +++ b/backend/src/controllers/WhatsAppSessionController.ts @@ -0,0 +1,40 @@ +import { Request, Response } from "express"; +import { getWbot } from "../libs/wbot"; +import ShowWhatsAppService from "../services/WhatsappService/ShowWhatsAppService"; +import { StartWhatsAppSession } from "../services/WbotServices/StartWhatsAppSession"; +import UpdateWhatsAppService from "../services/WhatsappService/UpdateWhatsAppService"; + +const store = async (req: Request, res: Response): Promise => { + const { whatsappId } = req.params; + const whatsapp = await ShowWhatsAppService(whatsappId); + + StartWhatsAppSession(whatsapp); + + return res.status(200).json({ message: "Starting session." }); +}; + +const update = async (req: Request, res: Response): Promise => { + const { whatsappId } = req.params; + + const { whatsapp } = await UpdateWhatsAppService({ + whatsappId, + whatsappData: { session: "" } + }); + + StartWhatsAppSession(whatsapp); + + return res.status(200).json({ message: "Starting session." }); +}; + +const remove = async (req: Request, res: Response): Promise => { + const { whatsappId } = req.params; + const whatsapp = await ShowWhatsAppService(whatsappId); + + const wbot = getWbot(whatsapp.id); + + wbot.logout(); + + return res.status(200).json({ message: "Session disconnected." }); +}; + +export default { store, remove, update }; diff --git a/backend/src/database/index.ts b/backend/src/database/index.ts new file mode 100644 index 0000000..4c230ac --- /dev/null +++ b/backend/src/database/index.ts @@ -0,0 +1,36 @@ +import { Sequelize } from "sequelize-typescript"; +import User from "../models/User"; +import Setting from "../models/Setting"; +import Contact from "../models/Contact"; +import Ticket from "../models/Ticket"; +import Whatsapp from "../models/Whatsapp"; +import ContactCustomField from "../models/ContactCustomField"; +import Message from "../models/Message"; +import Queue from "../models/Queue"; +import WhatsappQueue from "../models/WhatsappQueue"; +import UserQueue from "../models/UserQueue"; +import QuickAnswer from "../models/QuickAnswer"; + +// eslint-disable-next-line +const dbConfig = require("../config/database"); +// import dbConfig from "../config/database"; + +const sequelize = new Sequelize(dbConfig); + +const models = [ + User, + Contact, + Ticket, + Message, + Whatsapp, + ContactCustomField, + Setting, + Queue, + WhatsappQueue, + UserQueue, + QuickAnswer +]; + +sequelize.addModels(models); + +export default sequelize; diff --git a/backend/src/database/migrations/20200717133438-create-users.ts b/backend/src/database/migrations/20200717133438-create-users.ts new file mode 100644 index 0000000..17e9ee9 --- /dev/null +++ b/backend/src/database/migrations/20200717133438-create-users.ts @@ -0,0 +1,39 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("Users", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + passwordHash: { + type: DataTypes.STRING, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("Users"); + } +}; diff --git a/backend/src/database/migrations/20200717144403-create-contacts.ts b/backend/src/database/migrations/20200717144403-create-contacts.ts new file mode 100644 index 0000000..9224f66 --- /dev/null +++ b/backend/src/database/migrations/20200717144403-create-contacts.ts @@ -0,0 +1,38 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("Contacts", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + number: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + profilePicUrl: { + type: DataTypes.STRING + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("Contacts"); + } +}; diff --git a/backend/src/database/migrations/20200717145643-create-tickets.ts b/backend/src/database/migrations/20200717145643-create-tickets.ts new file mode 100644 index 0000000..d5016ee --- /dev/null +++ b/backend/src/database/migrations/20200717145643-create-tickets.ts @@ -0,0 +1,46 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("Tickets", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + status: { + type: DataTypes.STRING, + defaultValue: "pending", + allowNull: false + }, + lastMessage: { + type: DataTypes.STRING + }, + contactId: { + type: DataTypes.INTEGER, + references: { model: "Contacts", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE" + }, + userId: { + type: DataTypes.INTEGER, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL" + }, + createdAt: { + type: DataTypes.DATE(6), + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE(6), + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("Tickets"); + } +}; diff --git a/backend/src/database/migrations/20200717151645-create-messages.ts b/backend/src/database/migrations/20200717151645-create-messages.ts new file mode 100644 index 0000000..052dfc1 --- /dev/null +++ b/backend/src/database/migrations/20200717151645-create-messages.ts @@ -0,0 +1,58 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("Messages", { + id: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + body: { + type: DataTypes.TEXT, + allowNull: false + }, + ack: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + read: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + mediaType: { + type: DataTypes.STRING + }, + mediaUrl: { + type: DataTypes.STRING + }, + userId: { + type: DataTypes.INTEGER, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL" + }, + ticketId: { + type: DataTypes.INTEGER, + references: { model: "Tickets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + allowNull: false + }, + createdAt: { + type: DataTypes.DATE(6), + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE(6), + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("Messages"); + } +}; diff --git a/backend/src/database/migrations/20200717170223-create-whatsapps.ts b/backend/src/database/migrations/20200717170223-create-whatsapps.ts new file mode 100644 index 0000000..0686bc1 --- /dev/null +++ b/backend/src/database/migrations/20200717170223-create-whatsapps.ts @@ -0,0 +1,41 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("Whatsapps", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + session: { + type: DataTypes.TEXT + }, + qrcode: { + type: DataTypes.TEXT + }, + status: { + type: DataTypes.STRING + }, + battery: { + type: DataTypes.STRING + }, + plugged: { + type: DataTypes.BOOLEAN + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("Whatsapps"); + } +}; diff --git a/backend/src/database/migrations/20200723200315-create-contacts-custom-fields.ts b/backend/src/database/migrations/20200723200315-create-contacts-custom-fields.ts new file mode 100644 index 0000000..c6cc7f7 --- /dev/null +++ b/backend/src/database/migrations/20200723200315-create-contacts-custom-fields.ts @@ -0,0 +1,41 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("ContactCustomFields", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + value: { + type: DataTypes.STRING, + allowNull: false + }, + contactId: { + type: DataTypes.INTEGER, + references: { model: "Contacts", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("ContactCustomFields"); + } +}; diff --git a/backend/src/database/migrations/20200723202116-add-email-field-to-contacts.ts b/backend/src/database/migrations/20200723202116-add-email-field-to-contacts.ts new file mode 100644 index 0000000..cbf086d --- /dev/null +++ b/backend/src/database/migrations/20200723202116-add-email-field-to-contacts.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Contacts", "email", { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "" + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Contacts", "email"); + } +}; diff --git a/backend/src/database/migrations/20200730153237-remove-user-association-from-messages.ts b/backend/src/database/migrations/20200730153237-remove-user-association-from-messages.ts new file mode 100644 index 0000000..765619f --- /dev/null +++ b/backend/src/database/migrations/20200730153237-remove-user-association-from-messages.ts @@ -0,0 +1,16 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Messages", "userId"); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Messages", "userId", { + type: DataTypes.INTEGER, + references: { model: "Users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL" + }); + } +}; diff --git a/backend/src/database/migrations/20200730153545-add-fromMe-to-messages.ts b/backend/src/database/migrations/20200730153545-add-fromMe-to-messages.ts new file mode 100644 index 0000000..4bdcebe --- /dev/null +++ b/backend/src/database/migrations/20200730153545-add-fromMe-to-messages.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Messages", "fromMe", { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Messages", "fromMe"); + } +}; diff --git a/backend/src/database/migrations/20200813114236-change-ticket-lastMessage-column-type.ts b/backend/src/database/migrations/20200813114236-change-ticket-lastMessage-column-type.ts new file mode 100644 index 0000000..e4248e8 --- /dev/null +++ b/backend/src/database/migrations/20200813114236-change-ticket-lastMessage-column-type.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.changeColumn("Tickets", "lastMessage", { + type: DataTypes.TEXT + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.changeColumn("Tickets", "lastMessage", { + type: DataTypes.STRING + }); + } +}; diff --git a/backend/src/database/migrations/20200901235509-add-profile-column-to-users.ts b/backend/src/database/migrations/20200901235509-add-profile-column-to-users.ts new file mode 100644 index 0000000..b1d866d --- /dev/null +++ b/backend/src/database/migrations/20200901235509-add-profile-column-to-users.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Users", "profile", { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "admin" + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Users", "profile"); + } +}; diff --git a/backend/src/database/migrations/20200903215941-create-settings.ts b/backend/src/database/migrations/20200903215941-create-settings.ts new file mode 100644 index 0000000..b8724fc --- /dev/null +++ b/backend/src/database/migrations/20200903215941-create-settings.ts @@ -0,0 +1,29 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("Settings", { + key: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + value: { + type: DataTypes.TEXT, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("Settings"); + } +}; diff --git a/backend/src/database/migrations/20200904220257-add-name-to-whatsapp.ts b/backend/src/database/migrations/20200904220257-add-name-to-whatsapp.ts new file mode 100644 index 0000000..3d15507 --- /dev/null +++ b/backend/src/database/migrations/20200904220257-add-name-to-whatsapp.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Whatsapps", "name", { + type: DataTypes.STRING, + allowNull: false, + unique: true + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Whatsapps", "name"); + } +}; diff --git a/backend/src/database/migrations/20200906122228-add-name-default-field-to-whatsapp.ts b/backend/src/database/migrations/20200906122228-add-name-default-field-to-whatsapp.ts new file mode 100644 index 0000000..7ec4a50 --- /dev/null +++ b/backend/src/database/migrations/20200906122228-add-name-default-field-to-whatsapp.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Whatsapps", "default", { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Whatsapps", "default"); + } +}; diff --git a/backend/src/database/migrations/20200906155658-add-whatsapp-field-to-tickets.ts b/backend/src/database/migrations/20200906155658-add-whatsapp-field-to-tickets.ts new file mode 100644 index 0000000..5ed102d --- /dev/null +++ b/backend/src/database/migrations/20200906155658-add-whatsapp-field-to-tickets.ts @@ -0,0 +1,16 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Tickets", "whatsappId", { + type: DataTypes.INTEGER, + references: { model: "Whatsapps", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL" + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Tickets", "whatsappId"); + } +}; diff --git a/backend/src/database/migrations/20200919124112-update-default-column-name-on-whatsappp.ts b/backend/src/database/migrations/20200919124112-update-default-column-name-on-whatsappp.ts new file mode 100644 index 0000000..4821129 --- /dev/null +++ b/backend/src/database/migrations/20200919124112-update-default-column-name-on-whatsappp.ts @@ -0,0 +1,11 @@ +import { QueryInterface } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.renameColumn("Whatsapps", "default", "isDefault"); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.renameColumn("Whatsapps", "isDefault", "default"); + } +}; diff --git a/backend/src/database/migrations/20200927220708-add-isDeleted-column-to-messages.ts b/backend/src/database/migrations/20200927220708-add-isDeleted-column-to-messages.ts new file mode 100644 index 0000000..a3ffa86 --- /dev/null +++ b/backend/src/database/migrations/20200927220708-add-isDeleted-column-to-messages.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Messages", "isDeleted", { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Messages", "isDeleted"); + } +}; diff --git a/backend/src/database/migrations/20200929145451-add-user-tokenVersion-column.ts b/backend/src/database/migrations/20200929145451-add-user-tokenVersion-column.ts new file mode 100644 index 0000000..ceb5c21 --- /dev/null +++ b/backend/src/database/migrations/20200929145451-add-user-tokenVersion-column.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Users", "tokenVersion", { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Users", "tokenVersion"); + } +}; diff --git a/backend/src/database/migrations/20200930162323-add-isGroup-column-to-tickets.ts b/backend/src/database/migrations/20200930162323-add-isGroup-column-to-tickets.ts new file mode 100644 index 0000000..3e7ba47 --- /dev/null +++ b/backend/src/database/migrations/20200930162323-add-isGroup-column-to-tickets.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Tickets", "isGroup", { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Tickets", "isGroup"); + } +}; diff --git a/backend/src/database/migrations/20200930194808-add-isGroup-column-to-contacts.ts b/backend/src/database/migrations/20200930194808-add-isGroup-column-to-contacts.ts new file mode 100644 index 0000000..d2037ec --- /dev/null +++ b/backend/src/database/migrations/20200930194808-add-isGroup-column-to-contacts.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Contacts", "isGroup", { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Contacts", "isGroup"); + } +}; diff --git a/backend/src/database/migrations/20201004150008-add-contactId-column-to-messages.ts b/backend/src/database/migrations/20201004150008-add-contactId-column-to-messages.ts new file mode 100644 index 0000000..4b8f111 --- /dev/null +++ b/backend/src/database/migrations/20201004150008-add-contactId-column-to-messages.ts @@ -0,0 +1,16 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Messages", "contactId", { + type: DataTypes.INTEGER, + references: { model: "Contacts", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE" + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Messages", "contactId"); + } +}; diff --git a/backend/src/database/migrations/20201004155719-add-vcardContactId-column-to-messages.ts b/backend/src/database/migrations/20201004155719-add-vcardContactId-column-to-messages.ts new file mode 100644 index 0000000..d897363 --- /dev/null +++ b/backend/src/database/migrations/20201004155719-add-vcardContactId-column-to-messages.ts @@ -0,0 +1,16 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Messages", "vcardContactId", { + type: DataTypes.INTEGER, + references: { model: "Contacts", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE" + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Messages", "vcardContactId"); + } +}; diff --git a/backend/src/database/migrations/20201004955719-remove-vcardContactId-column-to-messages.ts b/backend/src/database/migrations/20201004955719-remove-vcardContactId-column-to-messages.ts new file mode 100644 index 0000000..dac0046 --- /dev/null +++ b/backend/src/database/migrations/20201004955719-remove-vcardContactId-column-to-messages.ts @@ -0,0 +1,16 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Messages", "vcardContactId"); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Messages", "vcardContactId", { + type: DataTypes.INTEGER, + references: { model: "Contacts", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE" + }); + } +}; diff --git a/backend/src/database/migrations/20201026215410-add-retries-to-whatsapps.ts b/backend/src/database/migrations/20201026215410-add-retries-to-whatsapps.ts new file mode 100644 index 0000000..57b1450 --- /dev/null +++ b/backend/src/database/migrations/20201026215410-add-retries-to-whatsapps.ts @@ -0,0 +1,15 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Whatsapps", "retries", { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Whatsapps", "retries"); + } +}; diff --git a/backend/src/database/migrations/20201028124427-add-quoted-msg-to-messages.ts b/backend/src/database/migrations/20201028124427-add-quoted-msg-to-messages.ts new file mode 100644 index 0000000..8bfd56f --- /dev/null +++ b/backend/src/database/migrations/20201028124427-add-quoted-msg-to-messages.ts @@ -0,0 +1,16 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Messages", "quotedMsgId", { + type: DataTypes.STRING, + references: { model: "Messages", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL" + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Messages", "quotedMsgId"); + } +}; diff --git a/backend/src/database/migrations/20210108001431-add-unreadMessages-to-tickets.ts b/backend/src/database/migrations/20210108001431-add-unreadMessages-to-tickets.ts new file mode 100644 index 0000000..ca5b47f --- /dev/null +++ b/backend/src/database/migrations/20210108001431-add-unreadMessages-to-tickets.ts @@ -0,0 +1,13 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Tickets", "unreadMessages", { + type: DataTypes.INTEGER + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Tickets", "unreadMessages"); + } +}; diff --git a/backend/src/database/migrations/20210108164404-create-queues.ts b/backend/src/database/migrations/20210108164404-create-queues.ts new file mode 100644 index 0000000..4a404d6 --- /dev/null +++ b/backend/src/database/migrations/20210108164404-create-queues.ts @@ -0,0 +1,39 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("Queues", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + color: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + greetingMessage: { + type: DataTypes.TEXT + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("Queues"); + } +}; diff --git a/backend/src/database/migrations/20210108164504-add-queueId-to-tickets.ts b/backend/src/database/migrations/20210108164504-add-queueId-to-tickets.ts new file mode 100644 index 0000000..6122b32 --- /dev/null +++ b/backend/src/database/migrations/20210108164504-add-queueId-to-tickets.ts @@ -0,0 +1,16 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Tickets", "queueId", { + type: DataTypes.INTEGER, + references: { model: "Queues", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL" + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Tickets", "queueId"); + } +}; diff --git a/backend/src/database/migrations/20210108174594-associate-whatsapp-queue.ts b/backend/src/database/migrations/20210108174594-associate-whatsapp-queue.ts new file mode 100644 index 0000000..0e08f71 --- /dev/null +++ b/backend/src/database/migrations/20210108174594-associate-whatsapp-queue.ts @@ -0,0 +1,28 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("WhatsappQueues", { + whatsappId: { + type: DataTypes.INTEGER, + primaryKey: true + }, + queueId: { + type: DataTypes.INTEGER, + primaryKey: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("WhatsappQueues"); + } +}; diff --git a/backend/src/database/migrations/20210108204708-associate-users-queue.ts b/backend/src/database/migrations/20210108204708-associate-users-queue.ts new file mode 100644 index 0000000..d92496a --- /dev/null +++ b/backend/src/database/migrations/20210108204708-associate-users-queue.ts @@ -0,0 +1,28 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("UserQueues", { + userId: { + type: DataTypes.INTEGER, + primaryKey: true + }, + queueId: { + type: DataTypes.INTEGER, + primaryKey: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("UserQueues"); + } +}; diff --git a/backend/src/database/migrations/20210109192513-add-greetingMessage-to-whatsapp.ts b/backend/src/database/migrations/20210109192513-add-greetingMessage-to-whatsapp.ts new file mode 100644 index 0000000..6d3c3be --- /dev/null +++ b/backend/src/database/migrations/20210109192513-add-greetingMessage-to-whatsapp.ts @@ -0,0 +1,13 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Whatsapps", "greetingMessage", { + type: DataTypes.TEXT + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Whatsapps", "greetingMessage"); + } +}; diff --git a/backend/src/database/migrations/20210818102605-create-quickAnswers.ts b/backend/src/database/migrations/20210818102605-create-quickAnswers.ts new file mode 100644 index 0000000..e7e81ee --- /dev/null +++ b/backend/src/database/migrations/20210818102605-create-quickAnswers.ts @@ -0,0 +1,34 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("QuickAnswers", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + shortcut: { + type: DataTypes.TEXT, + allowNull: false + }, + message: { + type: DataTypes.TEXT, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("QuickAnswers"); + } +}; diff --git a/backend/src/database/migrations/20211016014719-add-farewellMessage-to-whatsapp.ts b/backend/src/database/migrations/20211016014719-add-farewellMessage-to-whatsapp.ts new file mode 100644 index 0000000..40120bf --- /dev/null +++ b/backend/src/database/migrations/20211016014719-add-farewellMessage-to-whatsapp.ts @@ -0,0 +1,13 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Whatsapps", "farewellMessage", { + type: DataTypes.TEXT + }); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Whatsapps", "farewellMessage"); + } +}; diff --git a/backend/src/database/seeds/20200904070004-create-default-settings.ts b/backend/src/database/seeds/20200904070004-create-default-settings.ts new file mode 100644 index 0000000..802be29 --- /dev/null +++ b/backend/src/database/seeds/20200904070004-create-default-settings.ts @@ -0,0 +1,22 @@ +import { QueryInterface } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.bulkInsert( + "Settings", + [ + { + key: "userCreation", + value: "enabled", + createdAt: new Date(), + updatedAt: new Date() + } + ], + {} + ); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.bulkDelete("Settings", {}); + } +}; diff --git a/backend/src/database/seeds/20200904070004-create-default-users.ts b/backend/src/database/seeds/20200904070004-create-default-users.ts new file mode 100644 index 0000000..6549c1e --- /dev/null +++ b/backend/src/database/seeds/20200904070004-create-default-users.ts @@ -0,0 +1,25 @@ +import { QueryInterface } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.bulkInsert( + "Users", + [ + { + name: "Administrador", + email: "admin@whaticket.com", + passwordHash: "$2a$08$WaEmpmFDD/XkDqorkpQ42eUZozOqRCPkPcTkmHHMyuTGUOkI8dHsq", + profile: "admin", + tokenVersion: 0, + createdAt: new Date(), + updatedAt: new Date() + } + ], + {} + ); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.bulkDelete("Users", {}); + } +}; diff --git a/backend/src/errors/AppError.ts b/backend/src/errors/AppError.ts new file mode 100644 index 0000000..a8b1209 --- /dev/null +++ b/backend/src/errors/AppError.ts @@ -0,0 +1,12 @@ +class AppError { + public readonly message: string; + + public readonly statusCode: number; + + constructor(message: string, statusCode = 400) { + this.message = message; + this.statusCode = statusCode; + } +} + +export default AppError; diff --git a/backend/src/helpers/CheckContactOpenTickets.ts b/backend/src/helpers/CheckContactOpenTickets.ts new file mode 100644 index 0000000..3437cce --- /dev/null +++ b/backend/src/helpers/CheckContactOpenTickets.ts @@ -0,0 +1,15 @@ +import { Op } from "sequelize"; +import AppError from "../errors/AppError"; +import Ticket from "../models/Ticket"; + +const CheckContactOpenTickets = async (contactId: number): Promise => { + const ticket = await Ticket.findOne({ + where: { contactId, status: { [Op.or]: ["open", "pending"] } } + }); + + if (ticket) { + throw new AppError("ERR_OTHER_OPEN_TICKET"); + } +}; + +export default CheckContactOpenTickets; diff --git a/backend/src/helpers/CheckSettings.ts b/backend/src/helpers/CheckSettings.ts new file mode 100644 index 0000000..d19122b --- /dev/null +++ b/backend/src/helpers/CheckSettings.ts @@ -0,0 +1,16 @@ +import Setting from "../models/Setting"; +import AppError from "../errors/AppError"; + +const CheckSettings = async (key: string): Promise => { + const setting = await Setting.findOne({ + where: { key } + }); + + if (!setting) { + throw new AppError("ERR_NO_SETTING_FOUND", 404); + } + + return setting.value; +}; + +export default CheckSettings; diff --git a/backend/src/helpers/CreateTokens.ts b/backend/src/helpers/CreateTokens.ts new file mode 100644 index 0000000..ab9e19b --- /dev/null +++ b/backend/src/helpers/CreateTokens.ts @@ -0,0 +1,23 @@ +import { sign } from "jsonwebtoken"; +import authConfig from "../config/auth"; +import User from "../models/User"; + +export const createAccessToken = (user: User): string => { + const { secret, expiresIn } = authConfig; + + return sign( + { usarname: user.name, profile: user.profile, id: user.id }, + secret, + { + expiresIn + } + ); +}; + +export const createRefreshToken = (user: User): string => { + const { refreshSecret, refreshExpiresIn } = authConfig; + + return sign({ id: user.id, tokenVersion: user.tokenVersion }, refreshSecret, { + expiresIn: refreshExpiresIn + }); +}; diff --git a/backend/src/helpers/Debounce.ts b/backend/src/helpers/Debounce.ts new file mode 100644 index 0000000..80665d9 --- /dev/null +++ b/backend/src/helpers/Debounce.ts @@ -0,0 +1,41 @@ +interface Timeout { + id: number; + timeout: NodeJS.Timeout; +} + +const timeouts: Timeout[] = []; + +const findAndClearTimeout = (ticketId: number) => { + if (timeouts.length > 0) { + const timeoutIndex = timeouts.findIndex(timeout => timeout.id === ticketId); + + if (timeoutIndex !== -1) { + clearTimeout(timeouts[timeoutIndex].timeout); + timeouts.splice(timeoutIndex, 1); + } + } +}; + +const debounce = ( + func: { (): Promise; (...args: never[]): void }, + wait: number, + ticketId: number +) => { + return function executedFunction(...args: never[]): void { + const later = () => { + findAndClearTimeout(ticketId); + func(...args); + }; + + findAndClearTimeout(ticketId); + + const newTimeout = { + id: ticketId, + timeout: setTimeout(later, wait) + }; + + timeouts.push(newTimeout); + }; +}; + +export { debounce }; diff --git a/backend/src/helpers/GetDefaultWhatsApp.ts b/backend/src/helpers/GetDefaultWhatsApp.ts new file mode 100644 index 0000000..afcb362 --- /dev/null +++ b/backend/src/helpers/GetDefaultWhatsApp.ts @@ -0,0 +1,16 @@ +import AppError from "../errors/AppError"; +import Whatsapp from "../models/Whatsapp"; + +const GetDefaultWhatsApp = async (): Promise => { + const defaultWhatsapp = await Whatsapp.findOne({ + where: { isDefault: true } + }); + + if (!defaultWhatsapp) { + throw new AppError("ERR_NO_DEF_WAPP_FOUND"); + } + + return defaultWhatsapp; +}; + +export default GetDefaultWhatsApp; diff --git a/backend/src/helpers/GetTicketWbot.ts b/backend/src/helpers/GetTicketWbot.ts new file mode 100644 index 0000000..0caa6b0 --- /dev/null +++ b/backend/src/helpers/GetTicketWbot.ts @@ -0,0 +1,18 @@ +import { Client as Session } from "whatsapp-web.js"; +import { getWbot } from "../libs/wbot"; +import GetDefaultWhatsApp from "./GetDefaultWhatsApp"; +import Ticket from "../models/Ticket"; + +const GetTicketWbot = async (ticket: Ticket): Promise => { + if (!ticket.whatsappId) { + const defaultWhatsapp = await GetDefaultWhatsApp(); + + await ticket.$set("whatsapp", defaultWhatsapp); + } + + const wbot = getWbot(ticket.whatsappId); + + return wbot; +}; + +export default GetTicketWbot; diff --git a/backend/src/helpers/GetWbotMessage.ts b/backend/src/helpers/GetWbotMessage.ts new file mode 100644 index 0000000..baf0b79 --- /dev/null +++ b/backend/src/helpers/GetWbotMessage.ts @@ -0,0 +1,44 @@ +import { Message as WbotMessage } from "whatsapp-web.js"; +import Ticket from "../models/Ticket"; +import GetTicketWbot from "./GetTicketWbot"; +import AppError from "../errors/AppError"; + +export const GetWbotMessage = async ( + ticket: Ticket, + messageId: string +): Promise => { + const wbot = await GetTicketWbot(ticket); + + const wbotChat = await wbot.getChatById( + `${ticket.contact.number}@${ticket.isGroup ? "g" : "c"}.us` + ); + + let limit = 20; + + const fetchWbotMessagesGradually = async (): Promise => { + const chatMessages = await wbotChat.fetchMessages({ limit }); + + const msgFound = chatMessages.find(msg => msg.id.id === messageId); + + if (!msgFound && limit < 100) { + limit += 20; + return fetchWbotMessagesGradually(); + } + + return msgFound; + }; + + try { + const msgFound = await fetchWbotMessagesGradually(); + + if (!msgFound) { + throw new Error("Cannot found message within 100 last messages"); + } + + return msgFound; + } catch (err) { + throw new AppError("ERR_FETCH_WAPP_MSG"); + } +}; + +export default GetWbotMessage; diff --git a/backend/src/helpers/SendRefreshToken.ts b/backend/src/helpers/SendRefreshToken.ts new file mode 100644 index 0000000..4e4459a --- /dev/null +++ b/backend/src/helpers/SendRefreshToken.ts @@ -0,0 +1,5 @@ +import { Response } from "express"; + +export const SendRefreshToken = (res: Response, token: string): void => { + res.cookie("jrt", token, { httpOnly: true }); +}; diff --git a/backend/src/helpers/SerializeUser.ts b/backend/src/helpers/SerializeUser.ts new file mode 100644 index 0000000..3802500 --- /dev/null +++ b/backend/src/helpers/SerializeUser.ts @@ -0,0 +1,20 @@ +import Queue from "../models/Queue"; +import User from "../models/User"; + +interface SerializedUser { + id: number; + name: string; + email: string; + profile: string; + queues: Queue[]; +} + +export const SerializeUser = (user: User): SerializedUser => { + return { + id: user.id, + name: user.name, + email: user.email, + profile: user.profile, + queues: user.queues + }; +}; diff --git a/backend/src/helpers/SerializeWbotMsgId.ts b/backend/src/helpers/SerializeWbotMsgId.ts new file mode 100644 index 0000000..4b5886e --- /dev/null +++ b/backend/src/helpers/SerializeWbotMsgId.ts @@ -0,0 +1,12 @@ +import Message from "../models/Message"; +import Ticket from "../models/Ticket"; + +const SerializeWbotMsgId = (ticket: Ticket, message: Message): string => { + const serializedMsgId = `${message.fromMe}_${ticket.contact.number}@${ + ticket.isGroup ? "g" : "c" + }.us_${message.id}`; + + return serializedMsgId; +}; + +export default SerializeWbotMsgId; diff --git a/backend/src/helpers/SetTicketMessagesAsRead.ts b/backend/src/helpers/SetTicketMessagesAsRead.ts new file mode 100644 index 0000000..5350c81 --- /dev/null +++ b/backend/src/helpers/SetTicketMessagesAsRead.ts @@ -0,0 +1,38 @@ +import { getIO } from "../libs/socket"; +import Message from "../models/Message"; +import Ticket from "../models/Ticket"; +import { logger } from "../utils/logger"; +import GetTicketWbot from "./GetTicketWbot"; + +const SetTicketMessagesAsRead = async (ticket: Ticket): Promise => { + await Message.update( + { read: true }, + { + where: { + ticketId: ticket.id, + read: false + } + } + ); + + await ticket.update({ unreadMessages: 0 }); + + try { + const wbot = await GetTicketWbot(ticket); + await wbot.sendSeen( + `${ticket.contact.number}@${ticket.isGroup ? "g" : "c"}.us` + ); + } catch (err) { + logger.warn( + `Could not mark messages as read. Maybe whatsapp session disconnected? Err: ${err}` + ); + } + + const io = getIO(); + io.to(ticket.status).to("notification").emit("ticket", { + action: "updateUnread", + ticketId: ticket.id + }); +}; + +export default SetTicketMessagesAsRead; diff --git a/backend/src/helpers/UpdateDeletedUserOpenTicketsStatus.ts b/backend/src/helpers/UpdateDeletedUserOpenTicketsStatus.ts new file mode 100644 index 0000000..6e05ffb --- /dev/null +++ b/backend/src/helpers/UpdateDeletedUserOpenTicketsStatus.ts @@ -0,0 +1,17 @@ +import Ticket from "../models/Ticket"; +import UpdateTicketService from "../services/TicketServices/UpdateTicketService"; + +const UpdateDeletedUserOpenTicketsStatus = async ( + tickets: Ticket[] +): Promise => { + tickets.forEach(async t => { + const ticketId = t.id.toString(); + + await UpdateTicketService({ + ticketData: { status: "pending" }, + ticketId + }); + }); +}; + +export default UpdateDeletedUserOpenTicketsStatus; diff --git a/backend/src/libs/socket.ts b/backend/src/libs/socket.ts new file mode 100644 index 0000000..7169e23 --- /dev/null +++ b/backend/src/libs/socket.ts @@ -0,0 +1,44 @@ +import { Server as SocketIO } from "socket.io"; +import { Server } from "http"; +import AppError from "../errors/AppError"; +import { logger } from "../utils/logger"; + +let io: SocketIO; + +export const initIO = (httpServer: Server): SocketIO => { + io = new SocketIO(httpServer, { + cors: { + origin: process.env.FRONTEND_URL + } + }); + + io.on("connection", socket => { + logger.info("Client Connected"); + socket.on("joinChatBox", (ticketId: string) => { + logger.info("A client joined a ticket channel"); + socket.join(ticketId); + }); + + socket.on("joinNotification", () => { + logger.info("A client joined notification channel"); + socket.join("notification"); + }); + + socket.on("joinTickets", (status: string) => { + logger.info(`A client joined to ${status} tickets channel.`); + socket.join(status); + }); + + socket.on("disconnect", () => { + logger.info("Client disconnected"); + }); + }); + return io; +}; + +export const getIO = (): SocketIO => { + if (!io) { + throw new AppError("Socket IO not initialized"); + } + return io; +}; diff --git a/backend/src/libs/wbot.ts b/backend/src/libs/wbot.ts new file mode 100644 index 0000000..4febef7 --- /dev/null +++ b/backend/src/libs/wbot.ts @@ -0,0 +1,152 @@ +import qrCode from "qrcode-terminal"; +import { Client } from "whatsapp-web.js"; +import { getIO } from "./socket"; +import Whatsapp from "../models/Whatsapp"; +import AppError from "../errors/AppError"; +import { logger } from "../utils/logger"; +import { handleMessage } from "../services/WbotServices/wbotMessageListener"; + +interface Session extends Client { + id?: number; +} + +const sessions: Session[] = []; + +const syncUnreadMessages = async (wbot: Session) => { + const chats = await wbot.getChats(); + + /* eslint-disable no-restricted-syntax */ + /* eslint-disable no-await-in-loop */ + for (const chat of chats) { + if (chat.unreadCount > 0) { + const unreadMessages = await chat.fetchMessages({ + limit: chat.unreadCount + }); + + for (const msg of unreadMessages) { + await handleMessage(msg, wbot); + } + + await chat.sendSeen(); + } + } +}; + +export const initWbot = async (whatsapp: Whatsapp): Promise => { + return new Promise((resolve, reject) => { + try { + const io = getIO(); + const sessionName = whatsapp.name; + let sessionCfg; + + if (whatsapp && whatsapp.session) { + sessionCfg = JSON.parse(whatsapp.session); + } + + const wbot: Session = new Client({ + session: sessionCfg, + puppeteer: { + executablePath: process.env.CHROME_BIN || undefined + } + }); + + wbot.initialize(); + + wbot.on("qr", async qr => { + logger.info("Session:", sessionName); + qrCode.generate(qr, { small: true }); + await whatsapp.update({ qrcode: qr, status: "qrcode", retries: 0 }); + + const sessionIndex = sessions.findIndex(s => s.id === whatsapp.id); + if (sessionIndex === -1) { + wbot.id = whatsapp.id; + sessions.push(wbot); + } + + io.emit("whatsappSession", { + action: "update", + session: whatsapp + }); + }); + + wbot.on("authenticated", async session => { + logger.info(`Session: ${sessionName} AUTHENTICATED`); + await whatsapp.update({ + session: JSON.stringify(session) + }); + }); + + wbot.on("auth_failure", async msg => { + console.error( + `Session: ${sessionName} AUTHENTICATION FAILURE! Reason: ${msg}` + ); + + if (whatsapp.retries > 1) { + await whatsapp.update({ session: "", retries: 0 }); + } + + const retry = whatsapp.retries; + await whatsapp.update({ + status: "DISCONNECTED", + retries: retry + 1 + }); + + io.emit("whatsappSession", { + action: "update", + session: whatsapp + }); + + reject(new Error("Error starting whatsapp session.")); + }); + + wbot.on("ready", async () => { + logger.info(`Session: ${sessionName} READY`); + + await whatsapp.update({ + status: "CONNECTED", + qrcode: "", + retries: 0 + }); + + io.emit("whatsappSession", { + action: "update", + session: whatsapp + }); + + const sessionIndex = sessions.findIndex(s => s.id === whatsapp.id); + if (sessionIndex === -1) { + wbot.id = whatsapp.id; + sessions.push(wbot); + } + + wbot.sendPresenceAvailable(); + await syncUnreadMessages(wbot); + + resolve(wbot); + }); + } catch (err) { + logger.error(err); + } + }); +}; + +export const getWbot = (whatsappId: number): Session => { + const sessionIndex = sessions.findIndex(s => s.id === whatsappId); + + if (sessionIndex === -1) { + throw new AppError("ERR_WAPP_NOT_INITIALIZED"); + } + return sessions[sessionIndex]; +}; + +export const removeWbot = (whatsappId: number): void => { + try { + const sessionIndex = sessions.findIndex(s => s.id === whatsappId); + if (sessionIndex !== -1) { + sessions[sessionIndex].destroy(); + sessions.splice(sessionIndex, 1); + } + } catch (err) { + logger.error(err); + } +}; diff --git a/backend/src/middleware/isAuth.ts b/backend/src/middleware/isAuth.ts new file mode 100644 index 0000000..83cae2a --- /dev/null +++ b/backend/src/middleware/isAuth.ts @@ -0,0 +1,42 @@ +import { verify } from "jsonwebtoken"; +import { Request, Response, NextFunction } from "express"; + +import AppError from "../errors/AppError"; +import authConfig from "../config/auth"; + +interface TokenPayload { + id: string; + username: string; + profile: string; + iat: number; + exp: number; +} + +const isAuth = (req: Request, res: Response, next: NextFunction): void => { + const authHeader = req.headers.authorization; + + if (!authHeader) { + throw new AppError("ERR_SESSION_EXPIRED", 401); + } + + const [, token] = authHeader.split(" "); + + try { + const decoded = verify(token, authConfig.secret); + const { id, profile } = decoded as TokenPayload; + + req.user = { + id, + profile + }; + } catch (err) { + throw new AppError( + "Invalid token. We'll try to assign a new one on next request", + 403 + ); + } + + return next(); +}; + +export default isAuth; diff --git a/backend/src/models/Contact.ts b/backend/src/models/Contact.ts new file mode 100644 index 0000000..d7c4c93 --- /dev/null +++ b/backend/src/models/Contact.ts @@ -0,0 +1,57 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + PrimaryKey, + AutoIncrement, + AllowNull, + Unique, + Default, + HasMany +} from "sequelize-typescript"; +import ContactCustomField from "./ContactCustomField"; +import Ticket from "./Ticket"; + +@Table +class Contact extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @Column + name: string; + + @AllowNull(false) + @Unique + @Column + number: string; + + @AllowNull(false) + @Default("") + @Column + email: string; + + @Column + profilePicUrl: string; + + @Default(false) + @Column + isGroup: boolean; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; + + @HasMany(() => Ticket) + tickets: Ticket[]; + + @HasMany(() => ContactCustomField) + extraInfo: ContactCustomField[]; +} + +export default Contact; diff --git a/backend/src/models/ContactCustomField.ts b/backend/src/models/ContactCustomField.ts new file mode 100644 index 0000000..f4a9ebe --- /dev/null +++ b/backend/src/models/ContactCustomField.ts @@ -0,0 +1,41 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + PrimaryKey, + AutoIncrement, + ForeignKey, + BelongsTo +} from "sequelize-typescript"; +import Contact from "./Contact"; + +@Table +class ContactCustomField extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @Column + name: string; + + @Column + value: string; + + @ForeignKey(() => Contact) + @Column + contactId: number; + + @BelongsTo(() => Contact) + contact: Contact; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; +} + +export default ContactCustomField; diff --git a/backend/src/models/Message.ts b/backend/src/models/Message.ts new file mode 100644 index 0000000..4f49b88 --- /dev/null +++ b/backend/src/models/Message.ts @@ -0,0 +1,84 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + DataType, + PrimaryKey, + Default, + BelongsTo, + ForeignKey +} from "sequelize-typescript"; +import Contact from "./Contact"; +import Ticket from "./Ticket"; + +@Table +class Message extends Model { + @PrimaryKey + @Column + id: string; + + @Default(0) + @Column + ack: number; + + @Default(false) + @Column + read: boolean; + + @Default(false) + @Column + fromMe: boolean; + + @Column(DataType.TEXT) + body: string; + + @Column(DataType.STRING) + get mediaUrl(): string | null { + if (this.getDataValue("mediaUrl")) { + return `${process.env.BACKEND_URL}:${ + process.env.PROXY_PORT + }/public/${this.getDataValue("mediaUrl")}`; + } + return null; + } + + @Column + mediaType: string; + + @Default(false) + @Column + isDeleted: boolean; + + @CreatedAt + @Column(DataType.DATE(6)) + createdAt: Date; + + @UpdatedAt + @Column(DataType.DATE(6)) + updatedAt: Date; + + @ForeignKey(() => Message) + @Column + quotedMsgId: string; + + @BelongsTo(() => Message, "quotedMsgId") + quotedMsg: Message; + + @ForeignKey(() => Ticket) + @Column + ticketId: number; + + @BelongsTo(() => Ticket) + ticket: Ticket; + + @ForeignKey(() => Contact) + @Column + contactId: number; + + @BelongsTo(() => Contact, "contactId") + contact: Contact; +} + +export default Message; diff --git a/backend/src/models/Queue.ts b/backend/src/models/Queue.ts new file mode 100644 index 0000000..c5c06d9 --- /dev/null +++ b/backend/src/models/Queue.ts @@ -0,0 +1,52 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + PrimaryKey, + AutoIncrement, + AllowNull, + Unique, + BelongsToMany +} from "sequelize-typescript"; +import User from "./User"; +import UserQueue from "./UserQueue"; + +import Whatsapp from "./Whatsapp"; +import WhatsappQueue from "./WhatsappQueue"; + +@Table +class Queue extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @AllowNull(false) + @Unique + @Column + name: string; + + @AllowNull(false) + @Unique + @Column + color: string; + + @Column + greetingMessage: string; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; + + @BelongsToMany(() => Whatsapp, () => WhatsappQueue) + whatsapps: Array; + + @BelongsToMany(() => User, () => UserQueue) + users: Array; +} + +export default Queue; diff --git a/backend/src/models/QuickAnswer.ts b/backend/src/models/QuickAnswer.ts new file mode 100644 index 0000000..3549734 --- /dev/null +++ b/backend/src/models/QuickAnswer.ts @@ -0,0 +1,32 @@ +import { + Table, + Column, + DataType, + CreatedAt, + UpdatedAt, + Model, + PrimaryKey, + AutoIncrement +} from "sequelize-typescript"; + +@Table +class QuickAnswer extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @Column(DataType.TEXT) + shortcut: string; + + @Column(DataType.TEXT) + message: string; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; +} + +export default QuickAnswer; diff --git a/backend/src/models/Setting.ts b/backend/src/models/Setting.ts new file mode 100644 index 0000000..b58e57a --- /dev/null +++ b/backend/src/models/Setting.ts @@ -0,0 +1,26 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + PrimaryKey +} from "sequelize-typescript"; + +@Table +class Setting extends Model { + @PrimaryKey + @Column + key: string; + + @Column + value: string; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; +} + +export default Setting; diff --git a/backend/src/models/Ticket.ts b/backend/src/models/Ticket.ts new file mode 100644 index 0000000..8de4375 --- /dev/null +++ b/backend/src/models/Ticket.ts @@ -0,0 +1,79 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + PrimaryKey, + ForeignKey, + BelongsTo, + HasMany, + AutoIncrement, + Default +} from "sequelize-typescript"; + +import Contact from "./Contact"; +import Message from "./Message"; +import Queue from "./Queue"; +import User from "./User"; +import Whatsapp from "./Whatsapp"; + +@Table +class Ticket extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @Column({ defaultValue: "pending" }) + status: string; + + @Column + unreadMessages: number; + + @Column + lastMessage: string; + + @Default(false) + @Column + isGroup: boolean; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; + + @ForeignKey(() => User) + @Column + userId: number; + + @BelongsTo(() => User) + user: User; + + @ForeignKey(() => Contact) + @Column + contactId: number; + + @BelongsTo(() => Contact) + contact: Contact; + + @ForeignKey(() => Whatsapp) + @Column + whatsappId: number; + + @BelongsTo(() => Whatsapp) + whatsapp: Whatsapp; + + @ForeignKey(() => Queue) + @Column + queueId: number; + + @BelongsTo(() => Queue) + queue: Queue; + + @HasMany(() => Message) + messages: Message[]; +} + +export default Ticket; diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts new file mode 100644 index 0000000..9be664b --- /dev/null +++ b/backend/src/models/User.ts @@ -0,0 +1,73 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + DataType, + BeforeCreate, + BeforeUpdate, + PrimaryKey, + AutoIncrement, + Default, + HasMany, + BelongsToMany +} from "sequelize-typescript"; +import { hash, compare } from "bcryptjs"; +import Ticket from "./Ticket"; +import Queue from "./Queue"; +import UserQueue from "./UserQueue"; + +@Table +class User extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @Column + name: string; + + @Column + email: string; + + @Column(DataType.VIRTUAL) + password: string; + + @Column + passwordHash: string; + + @Default(0) + @Column + tokenVersion: number; + + @Default("admin") + @Column + profile: string; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; + + @HasMany(() => Ticket) + tickets: Ticket[]; + + @BelongsToMany(() => Queue, () => UserQueue) + queues: Queue[]; + + @BeforeUpdate + @BeforeCreate + static hashPassword = async (instance: User): Promise => { + if (instance.password) { + instance.passwordHash = await hash(instance.password, 8); + } + }; + + public checkPassword = async (password: string): Promise => { + return compare(password, this.getDataValue("passwordHash")); + }; +} + +export default User; diff --git a/backend/src/models/UserQueue.ts b/backend/src/models/UserQueue.ts new file mode 100644 index 0000000..17528c2 --- /dev/null +++ b/backend/src/models/UserQueue.ts @@ -0,0 +1,29 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + ForeignKey +} from "sequelize-typescript"; +import Queue from "./Queue"; +import User from "./User"; + +@Table +class UserQueue extends Model { + @ForeignKey(() => User) + @Column + userId: number; + + @ForeignKey(() => Queue) + @Column + queueId: number; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; +} + +export default UserQueue; diff --git a/backend/src/models/Whatsapp.ts b/backend/src/models/Whatsapp.ts new file mode 100644 index 0000000..8442faa --- /dev/null +++ b/backend/src/models/Whatsapp.ts @@ -0,0 +1,77 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + DataType, + PrimaryKey, + AutoIncrement, + Default, + AllowNull, + HasMany, + Unique, + BelongsToMany +} from "sequelize-typescript"; +import Queue from "./Queue"; +import Ticket from "./Ticket"; +import WhatsappQueue from "./WhatsappQueue"; + +@Table +class Whatsapp extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @AllowNull + @Unique + @Column(DataType.TEXT) + name: string; + + @Column(DataType.TEXT) + session: string; + + @Column(DataType.TEXT) + qrcode: string; + + @Column + status: string; + + @Column + battery: string; + + @Column + plugged: boolean; + + @Column + retries: number; + + @Column(DataType.TEXT) + greetingMessage: string; + + @Column(DataType.TEXT) + farewellMessage: string; + + @Default(false) + @AllowNull + @Column + isDefault: boolean; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; + + @HasMany(() => Ticket) + tickets: Ticket[]; + + @BelongsToMany(() => Queue, () => WhatsappQueue) + queues: Array; + + @HasMany(() => WhatsappQueue) + whatsappQueues: WhatsappQueue[]; +} + +export default Whatsapp; diff --git a/backend/src/models/WhatsappQueue.ts b/backend/src/models/WhatsappQueue.ts new file mode 100644 index 0000000..b68aaa0 --- /dev/null +++ b/backend/src/models/WhatsappQueue.ts @@ -0,0 +1,33 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + ForeignKey, + BelongsTo +} from "sequelize-typescript"; +import Queue from "./Queue"; +import Whatsapp from "./Whatsapp"; + +@Table +class WhatsappQueue extends Model { + @ForeignKey(() => Whatsapp) + @Column + whatsappId: number; + + @ForeignKey(() => Queue) + @Column + queueId: number; + + @CreatedAt + createdAt: Date; + + @UpdatedAt + updatedAt: Date; + + @BelongsTo(() => Queue) + queue: Queue; +} + +export default WhatsappQueue; diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts new file mode 100644 index 0000000..8428fe9 --- /dev/null +++ b/backend/src/routes/authRoutes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import * as SessionController from "../controllers/SessionController"; +import * as UserController from "../controllers/UserController"; +import isAuth from "../middleware/isAuth"; + +const authRoutes = Router(); + +authRoutes.post("/signup", UserController.store); + +authRoutes.post("/login", SessionController.store); + +authRoutes.post("/refresh_token", SessionController.update); + +authRoutes.delete("/logout", isAuth, SessionController.remove); + +export default authRoutes; diff --git a/backend/src/routes/contactRoutes.ts b/backend/src/routes/contactRoutes.ts new file mode 100644 index 0000000..5bbc100 --- /dev/null +++ b/backend/src/routes/contactRoutes.ts @@ -0,0 +1,25 @@ +import express from "express"; +import isAuth from "../middleware/isAuth"; + +import * as ContactController from "../controllers/ContactController"; +import * as ImportPhoneContactsController from "../controllers/ImportPhoneContactsController"; + +const contactRoutes = express.Router(); + +contactRoutes.post( + "/contacts/import", + isAuth, + ImportPhoneContactsController.store +); + +contactRoutes.get("/contacts", isAuth, ContactController.index); + +contactRoutes.get("/contacts/:contactId", isAuth, ContactController.show); + +contactRoutes.post("/contacts", isAuth, ContactController.store); + +contactRoutes.put("/contacts/:contactId", isAuth, ContactController.update); + +contactRoutes.delete("/contacts/:contactId", isAuth, ContactController.remove); + +export default contactRoutes; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts new file mode 100644 index 0000000..36d14bd --- /dev/null +++ b/backend/src/routes/index.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; + +import userRoutes from "./userRoutes"; +import authRoutes from "./authRoutes"; +import settingRoutes from "./settingRoutes"; +import contactRoutes from "./contactRoutes"; +import ticketRoutes from "./ticketRoutes"; +import whatsappRoutes from "./whatsappRoutes"; +import messageRoutes from "./messageRoutes"; +import whatsappSessionRoutes from "./whatsappSessionRoutes"; +import queueRoutes from "./queueRoutes"; +import quickAnswerRoutes from "./quickAnswerRoutes"; + +const routes = Router(); + +routes.use(userRoutes); +routes.use("/auth", authRoutes); +routes.use(settingRoutes); +routes.use(contactRoutes); +routes.use(ticketRoutes); +routes.use(whatsappRoutes); +routes.use(messageRoutes); +routes.use(whatsappSessionRoutes); +routes.use(queueRoutes); +routes.use(quickAnswerRoutes); + +export default routes; diff --git a/backend/src/routes/messageRoutes.ts b/backend/src/routes/messageRoutes.ts new file mode 100644 index 0000000..a97303b --- /dev/null +++ b/backend/src/routes/messageRoutes.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +import multer from "multer"; +import isAuth from "../middleware/isAuth"; +import uploadConfig from "../config/upload"; + +import * as MessageController from "../controllers/MessageController"; + +const messageRoutes = Router(); + +const upload = multer(uploadConfig); + +messageRoutes.get("/messages/:ticketId", isAuth, MessageController.index); + +messageRoutes.post( + "/messages/:ticketId", + isAuth, + upload.array("medias"), + MessageController.store +); + +messageRoutes.delete("/messages/:messageId", isAuth, MessageController.remove); + +export default messageRoutes; diff --git a/backend/src/routes/queueRoutes.ts b/backend/src/routes/queueRoutes.ts new file mode 100644 index 0000000..a85f5e3 --- /dev/null +++ b/backend/src/routes/queueRoutes.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import isAuth from "../middleware/isAuth"; + +import * as QueueController from "../controllers/QueueController"; + +const queueRoutes = Router(); + +queueRoutes.get("/queue", isAuth, QueueController.index); + +queueRoutes.post("/queue", isAuth, QueueController.store); + +queueRoutes.get("/queue/:queueId", isAuth, QueueController.show); + +queueRoutes.put("/queue/:queueId", isAuth, QueueController.update); + +queueRoutes.delete("/queue/:queueId", isAuth, QueueController.remove); + +export default queueRoutes; diff --git a/backend/src/routes/quickAnswerRoutes.ts b/backend/src/routes/quickAnswerRoutes.ts new file mode 100644 index 0000000..eab4557 --- /dev/null +++ b/backend/src/routes/quickAnswerRoutes.ts @@ -0,0 +1,30 @@ +import express from "express"; +import isAuth from "../middleware/isAuth"; + +import * as QuickAnswerController from "../controllers/QuickAnswerController"; + +const quickAnswerRoutes = express.Router(); + +quickAnswerRoutes.get("/quickAnswers", isAuth, QuickAnswerController.index); + +quickAnswerRoutes.get( + "/quickAnswers/:quickAnswerId", + isAuth, + QuickAnswerController.show +); + +quickAnswerRoutes.post("/quickAnswers", isAuth, QuickAnswerController.store); + +quickAnswerRoutes.put( + "/quickAnswers/:quickAnswerId", + isAuth, + QuickAnswerController.update +); + +quickAnswerRoutes.delete( + "/quickAnswers/:quickAnswerId", + isAuth, + QuickAnswerController.remove +); + +export default quickAnswerRoutes; diff --git a/backend/src/routes/settingRoutes.ts b/backend/src/routes/settingRoutes.ts new file mode 100644 index 0000000..625864a --- /dev/null +++ b/backend/src/routes/settingRoutes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import isAuth from "../middleware/isAuth"; + +import * as SettingController from "../controllers/SettingController"; + +const settingRoutes = Router(); + +settingRoutes.get("/settings", isAuth, SettingController.index); + +// routes.get("/settings/:settingKey", isAuth, SettingsController.show); + +// change setting key to key in future +settingRoutes.put("/settings/:settingKey", isAuth, SettingController.update); + +export default settingRoutes; diff --git a/backend/src/routes/ticketRoutes.ts b/backend/src/routes/ticketRoutes.ts new file mode 100644 index 0000000..41f41e7 --- /dev/null +++ b/backend/src/routes/ticketRoutes.ts @@ -0,0 +1,18 @@ +import express from "express"; +import isAuth from "../middleware/isAuth"; + +import * as TicketController from "../controllers/TicketController"; + +const ticketRoutes = express.Router(); + +ticketRoutes.get("/tickets", isAuth, TicketController.index); + +ticketRoutes.get("/tickets/:ticketId", isAuth, TicketController.show); + +ticketRoutes.post("/tickets", isAuth, TicketController.store); + +ticketRoutes.put("/tickets/:ticketId", isAuth, TicketController.update); + +ticketRoutes.delete("/tickets/:ticketId", isAuth, TicketController.remove); + +export default ticketRoutes; diff --git a/backend/src/routes/userRoutes.ts b/backend/src/routes/userRoutes.ts new file mode 100644 index 0000000..ad02251 --- /dev/null +++ b/backend/src/routes/userRoutes.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; + +import isAuth from "../middleware/isAuth"; +import * as UserController from "../controllers/UserController"; + +const userRoutes = Router(); + +userRoutes.get("/users", isAuth, UserController.index); + +userRoutes.post("/users", isAuth, UserController.store); + +userRoutes.put("/users/:userId", isAuth, UserController.update); + +userRoutes.get("/users/:userId", isAuth, UserController.show); + +userRoutes.delete("/users/:userId", isAuth, UserController.remove); + +export default userRoutes; diff --git a/backend/src/routes/whatsappRoutes.ts b/backend/src/routes/whatsappRoutes.ts new file mode 100644 index 0000000..dc187a7 --- /dev/null +++ b/backend/src/routes/whatsappRoutes.ts @@ -0,0 +1,22 @@ +import express from "express"; +import isAuth from "../middleware/isAuth"; + +import * as WhatsAppController from "../controllers/WhatsAppController"; + +const whatsappRoutes = express.Router(); + +whatsappRoutes.get("/whatsapp/", isAuth, WhatsAppController.index); + +whatsappRoutes.post("/whatsapp/", isAuth, WhatsAppController.store); + +whatsappRoutes.get("/whatsapp/:whatsappId", isAuth, WhatsAppController.show); + +whatsappRoutes.put("/whatsapp/:whatsappId", isAuth, WhatsAppController.update); + +whatsappRoutes.delete( + "/whatsapp/:whatsappId", + isAuth, + WhatsAppController.remove +); + +export default whatsappRoutes; diff --git a/backend/src/routes/whatsappSessionRoutes.ts b/backend/src/routes/whatsappSessionRoutes.ts new file mode 100644 index 0000000..731d847 --- /dev/null +++ b/backend/src/routes/whatsappSessionRoutes.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import isAuth from "../middleware/isAuth"; + +import WhatsAppSessionController from "../controllers/WhatsAppSessionController"; + +const whatsappSessionRoutes = Router(); + +whatsappSessionRoutes.post( + "/whatsappsession/:whatsappId", + isAuth, + WhatsAppSessionController.store +); + +whatsappSessionRoutes.put( + "/whatsappsession/:whatsappId", + isAuth, + WhatsAppSessionController.update +); + +whatsappSessionRoutes.delete( + "/whatsappsession/:whatsappId", + isAuth, + WhatsAppSessionController.remove +); + +export default whatsappSessionRoutes; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..b76e73c --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,13 @@ +import gracefulShutdown from "http-graceful-shutdown"; +import app from "./app"; +import { initIO } from "./libs/socket"; +import { logger } from "./utils/logger"; +import { StartAllWhatsAppsSessions } from "./services/WbotServices/StartAllWhatsAppsSessions"; + +const server = app.listen(process.env.PORT, () => { + logger.info(`Server started on port: ${process.env.PORT}`); +}); + +initIO(server); +StartAllWhatsAppsSessions(); +gracefulShutdown(server); diff --git a/backend/src/services/AuthServices/RefreshTokenService.ts b/backend/src/services/AuthServices/RefreshTokenService.ts new file mode 100644 index 0000000..727cdfa --- /dev/null +++ b/backend/src/services/AuthServices/RefreshTokenService.ts @@ -0,0 +1,47 @@ +import { verify } from "jsonwebtoken"; +import { Response as Res } from "express"; + +import User from "../../models/User"; +import AppError from "../../errors/AppError"; +import ShowUserService from "../UserServices/ShowUserService"; +import authConfig from "../../config/auth"; +import { + createAccessToken, + createRefreshToken +} from "../../helpers/CreateTokens"; + +interface RefreshTokenPayload { + id: string; + tokenVersion: number; +} + +interface Response { + user: User; + newToken: string; + refreshToken: string; +} + +export const RefreshTokenService = async ( + res: Res, + token: string +): Promise => { + try { + const decoded = verify(token, authConfig.refreshSecret); + const { id, tokenVersion } = decoded as RefreshTokenPayload; + + const user = await ShowUserService(id); + + if (user.tokenVersion !== tokenVersion) { + res.clearCookie("jrt"); + throw new AppError("ERR_SESSION_EXPIRED", 401); + } + + const newToken = createAccessToken(user); + const refreshToken = createRefreshToken(user); + + return { user, newToken, refreshToken }; + } catch (err) { + res.clearCookie("jrt"); + throw new AppError("ERR_SESSION_EXPIRED", 401); + } +}; diff --git a/backend/src/services/ContactServices/CreateContactService.ts b/backend/src/services/ContactServices/CreateContactService.ts new file mode 100644 index 0000000..331d175 --- /dev/null +++ b/backend/src/services/ContactServices/CreateContactService.ts @@ -0,0 +1,46 @@ +import AppError from "../../errors/AppError"; +import Contact from "../../models/Contact"; + +interface ExtraInfo { + name: string; + value: string; +} + +interface Request { + name: string; + number: string; + email?: string; + profilePicUrl?: string; + extraInfo?: ExtraInfo[]; +} + +const CreateContactService = async ({ + name, + number, + email = "", + extraInfo = [] +}: Request): Promise => { + const numberExists = await Contact.findOne({ + where: { number } + }); + + if (numberExists) { + throw new AppError("ERR_DUPLICATED_CONTACT"); + } + + const contact = await Contact.create( + { + name, + number, + email, + extraInfo + }, + { + include: ["extraInfo"] + } + ); + + return contact; +}; + +export default CreateContactService; diff --git a/backend/src/services/ContactServices/CreateOrUpdateContactService.ts b/backend/src/services/ContactServices/CreateOrUpdateContactService.ts new file mode 100644 index 0000000..10a25af --- /dev/null +++ b/backend/src/services/ContactServices/CreateOrUpdateContactService.ts @@ -0,0 +1,59 @@ +import { getIO } from "../../libs/socket"; +import Contact from "../../models/Contact"; + +interface ExtraInfo { + name: string; + value: string; +} + +interface Request { + name: string; + number: string; + isGroup: boolean; + email?: string; + profilePicUrl?: string; + extraInfo?: ExtraInfo[]; +} + +const CreateOrUpdateContactService = async ({ + name, + number: rawNumber, + profilePicUrl, + isGroup, + email = "", + extraInfo = [] +}: Request): Promise => { + const number = isGroup ? rawNumber : rawNumber.replace(/[^0-9]/g, ""); + + const io = getIO(); + let contact: Contact | null; + + contact = await Contact.findOne({ where: { number } }); + + if (contact) { + contact.update({ profilePicUrl }); + + io.emit("contact", { + action: "update", + contact + }); + } else { + contact = await Contact.create({ + name, + number, + profilePicUrl, + email, + isGroup, + extraInfo + }); + + io.emit("contact", { + action: "create", + contact + }); + } + + return contact; +}; + +export default CreateOrUpdateContactService; diff --git a/backend/src/services/ContactServices/DeleteContactService.ts b/backend/src/services/ContactServices/DeleteContactService.ts new file mode 100644 index 0000000..caaf86a --- /dev/null +++ b/backend/src/services/ContactServices/DeleteContactService.ts @@ -0,0 +1,16 @@ +import Contact from "../../models/Contact"; +import AppError from "../../errors/AppError"; + +const DeleteContactService = async (id: string): Promise => { + const contact = await Contact.findOne({ + where: { id } + }); + + if (!contact) { + throw new AppError("ERR_NO_CONTACT_FOUND", 404); + } + + await contact.destroy(); +}; + +export default DeleteContactService; diff --git a/backend/src/services/ContactServices/ListContactsService.ts b/backend/src/services/ContactServices/ListContactsService.ts new file mode 100644 index 0000000..0bd7a38 --- /dev/null +++ b/backend/src/services/ContactServices/ListContactsService.ts @@ -0,0 +1,50 @@ +import { Sequelize, Op } from "sequelize"; +import Contact from "../../models/Contact"; + +interface Request { + searchParam?: string; + pageNumber?: string; +} + +interface Response { + contacts: Contact[]; + count: number; + hasMore: boolean; +} + +const ListContactsService = async ({ + searchParam = "", + pageNumber = "1" +}: Request): Promise => { + const whereCondition = { + [Op.or]: [ + { + name: Sequelize.where( + Sequelize.fn("LOWER", Sequelize.col("name")), + "LIKE", + `%${searchParam.toLowerCase().trim()}%` + ) + }, + { number: { [Op.like]: `%${searchParam.toLowerCase().trim()}%` } } + ] + }; + const limit = 20; + const offset = limit * (+pageNumber - 1); + + const { count, rows: contacts } = await Contact.findAndCountAll({ + where: whereCondition, + limit, + offset, + order: [["name", "ASC"]] + }); + + const hasMore = count > offset + contacts.length; + + return { + contacts, + count, + hasMore + }; +}; + +export default ListContactsService; diff --git a/backend/src/services/ContactServices/ShowContactService.ts b/backend/src/services/ContactServices/ShowContactService.ts new file mode 100644 index 0000000..4b215c4 --- /dev/null +++ b/backend/src/services/ContactServices/ShowContactService.ts @@ -0,0 +1,14 @@ +import Contact from "../../models/Contact"; +import AppError from "../../errors/AppError"; + +const ShowContactService = async (id: string | number): Promise => { + const contact = await Contact.findByPk(id, { include: ["extraInfo"] }); + + if (!contact) { + throw new AppError("ERR_NO_CONTACT_FOUND", 404); + } + + return contact; +}; + +export default ShowContactService; diff --git a/backend/src/services/ContactServices/UpdateContactService.ts b/backend/src/services/ContactServices/UpdateContactService.ts new file mode 100644 index 0000000..8211766 --- /dev/null +++ b/backend/src/services/ContactServices/UpdateContactService.ts @@ -0,0 +1,70 @@ +import AppError from "../../errors/AppError"; +import Contact from "../../models/Contact"; +import ContactCustomField from "../../models/ContactCustomField"; + +interface ExtraInfo { + id?: number; + name: string; + value: string; +} +interface ContactData { + email?: string; + number?: string; + name?: string; + extraInfo?: ExtraInfo[]; +} + +interface Request { + contactData: ContactData; + contactId: string; +} + +const UpdateContactService = async ({ + contactData, + contactId +}: Request): Promise => { + const { email, name, number, extraInfo } = contactData; + + const contact = await Contact.findOne({ + where: { id: contactId }, + attributes: ["id", "name", "number", "email", "profilePicUrl"], + include: ["extraInfo"] + }); + + if (!contact) { + throw new AppError("ERR_NO_CONTACT_FOUND", 404); + } + + if (extraInfo) { + await Promise.all( + extraInfo.map(async info => { + await ContactCustomField.upsert({ ...info, contactId: contact.id }); + }) + ); + + await Promise.all( + contact.extraInfo.map(async oldInfo => { + const stillExists = extraInfo.findIndex(info => info.id === oldInfo.id); + + if (stillExists === -1) { + await ContactCustomField.destroy({ where: { id: oldInfo.id } }); + } + }) + ); + } + + await contact.update({ + name, + number, + email + }); + + await contact.reload({ + attributes: ["id", "name", "number", "email", "profilePicUrl"], + include: ["extraInfo"] + }); + + return contact; +}; + +export default UpdateContactService; diff --git a/backend/src/services/MessageServices/CreateMessageService.ts b/backend/src/services/MessageServices/CreateMessageService.ts new file mode 100644 index 0000000..67fd33b --- /dev/null +++ b/backend/src/services/MessageServices/CreateMessageService.ts @@ -0,0 +1,58 @@ +import { getIO } from "../../libs/socket"; +import Message from "../../models/Message"; +import Ticket from "../../models/Ticket"; + +interface MessageData { + id: string; + ticketId: number; + body: string; + contactId?: number; + fromMe?: boolean; + read?: boolean; + mediaType?: string; + mediaUrl?: string; +} +interface Request { + messageData: MessageData; +} + +const CreateMessageService = async ({ + messageData +}: Request): Promise => { + await Message.upsert(messageData); + + const message = await Message.findByPk(messageData.id, { + include: [ + "contact", + { + model: Ticket, + as: "ticket", + include: ["contact", "queue"] + }, + { + model: Message, + as: "quotedMsg", + include: ["contact"] + } + ] + }); + + if (!message) { + throw new Error("ERR_CREATING_MESSAGE"); + } + + const io = getIO(); + io.to(message.ticketId.toString()) + .to(message.ticket.status) + .to("notification") + .emit("appMessage", { + action: "create", + message, + ticket: message.ticket, + contact: message.ticket.contact + }); + + return message; +}; + +export default CreateMessageService; diff --git a/backend/src/services/MessageServices/ListMessagesService.ts b/backend/src/services/MessageServices/ListMessagesService.ts new file mode 100644 index 0000000..459f2a6 --- /dev/null +++ b/backend/src/services/MessageServices/ListMessagesService.ts @@ -0,0 +1,57 @@ +import AppError from "../../errors/AppError"; +import Message from "../../models/Message"; +import Ticket from "../../models/Ticket"; +import ShowTicketService from "../TicketServices/ShowTicketService"; + +interface Request { + ticketId: string; + pageNumber?: string; +} + +interface Response { + messages: Message[]; + ticket: Ticket; + count: number; + hasMore: boolean; +} + +const ListMessagesService = async ({ + pageNumber = "1", + ticketId +}: Request): Promise => { + const ticket = await ShowTicketService(ticketId); + + if (!ticket) { + throw new AppError("ERR_NO_TICKET_FOUND", 404); + } + + // await setMessagesAsRead(ticket); + const limit = 20; + const offset = limit * (+pageNumber - 1); + + const { count, rows: messages } = await Message.findAndCountAll({ + where: { ticketId }, + limit, + include: [ + "contact", + { + model: Message, + as: "quotedMsg", + include: ["contact"] + } + ], + offset, + order: [["createdAt", "DESC"]] + }); + + const hasMore = count > offset + messages.length; + + return { + messages: messages.reverse(), + ticket, + count, + hasMore + }; +}; + +export default ListMessagesService; diff --git a/backend/src/services/QueueService/CreateQueueService.ts b/backend/src/services/QueueService/CreateQueueService.ts new file mode 100644 index 0000000..57881e1 --- /dev/null +++ b/backend/src/services/QueueService/CreateQueueService.ts @@ -0,0 +1,67 @@ +import * as Yup from "yup"; +import AppError from "../../errors/AppError"; +import Queue from "../../models/Queue"; + +interface QueueData { + name: string; + color: string; + greetingMessage?: string; +} + +const CreateQueueService = async (queueData: QueueData): Promise => { + const { color, name } = queueData; + + const queueSchema = Yup.object().shape({ + name: Yup.string() + .min(2, "ERR_QUEUE_INVALID_NAME") + .required("ERR_QUEUE_INVALID_NAME") + .test( + "Check-unique-name", + "ERR_QUEUE_NAME_ALREADY_EXISTS", + async value => { + if (value) { + const queueWithSameName = await Queue.findOne({ + where: { name: value } + }); + + return !queueWithSameName; + } + return false; + } + ), + color: Yup.string() + .required("ERR_QUEUE_INVALID_COLOR") + .test("Check-color", "ERR_QUEUE_INVALID_COLOR", async value => { + if (value) { + const colorTestRegex = /^#[0-9a-f]{3,6}$/i; + return colorTestRegex.test(value); + } + return false; + }) + .test( + "Check-color-exists", + "ERR_QUEUE_COLOR_ALREADY_EXISTS", + async value => { + if (value) { + const queueWithSameColor = await Queue.findOne({ + where: { color: value } + }); + return !queueWithSameColor; + } + return false; + } + ) + }); + + try { + await queueSchema.validate({ color, name }); + } catch (err) { + throw new AppError(err.message); + } + + const queue = await Queue.create(queueData); + + return queue; +}; + +export default CreateQueueService; diff --git a/backend/src/services/QueueService/DeleteQueueService.ts b/backend/src/services/QueueService/DeleteQueueService.ts new file mode 100644 index 0000000..fcf9ef6 --- /dev/null +++ b/backend/src/services/QueueService/DeleteQueueService.ts @@ -0,0 +1,9 @@ +import ShowQueueService from "./ShowQueueService"; + +const DeleteQueueService = async (queueId: number | string): Promise => { + const queue = await ShowQueueService(queueId); + + await queue.destroy(); +}; + +export default DeleteQueueService; diff --git a/backend/src/services/QueueService/ListQueuesService.ts b/backend/src/services/QueueService/ListQueuesService.ts new file mode 100644 index 0000000..204d9a1 --- /dev/null +++ b/backend/src/services/QueueService/ListQueuesService.ts @@ -0,0 +1,9 @@ +import Queue from "../../models/Queue"; + +const ListQueuesService = async (): Promise => { + const queues = await Queue.findAll({ order: [["name", "ASC"]] }); + + return queues; +}; + +export default ListQueuesService; diff --git a/backend/src/services/QueueService/ShowQueueService.ts b/backend/src/services/QueueService/ShowQueueService.ts new file mode 100644 index 0000000..16ade45 --- /dev/null +++ b/backend/src/services/QueueService/ShowQueueService.ts @@ -0,0 +1,14 @@ +import AppError from "../../errors/AppError"; +import Queue from "../../models/Queue"; + +const ShowQueueService = async (queueId: number | string): Promise => { + const queue = await Queue.findByPk(queueId); + + if (!queue) { + throw new AppError("ERR_QUEUE_NOT_FOUND"); + } + + return queue; +}; + +export default ShowQueueService; diff --git a/backend/src/services/QueueService/UpdateQueueService.ts b/backend/src/services/QueueService/UpdateQueueService.ts new file mode 100644 index 0000000..8aa2a23 --- /dev/null +++ b/backend/src/services/QueueService/UpdateQueueService.ts @@ -0,0 +1,73 @@ +import { Op } from "sequelize"; +import * as Yup from "yup"; +import AppError from "../../errors/AppError"; +import Queue from "../../models/Queue"; +import ShowQueueService from "./ShowQueueService"; + +interface QueueData { + name?: string; + color?: string; + greetingMessage?: string; +} + +const UpdateQueueService = async ( + queueId: number | string, + queueData: QueueData +): Promise => { + const { color, name } = queueData; + + const queueSchema = Yup.object().shape({ + name: Yup.string() + .min(2, "ERR_QUEUE_INVALID_NAME") + .test( + "Check-unique-name", + "ERR_QUEUE_NAME_ALREADY_EXISTS", + async value => { + if (value) { + const queueWithSameName = await Queue.findOne({ + where: { name: value, id: { [Op.not]: queueId } } + }); + + return !queueWithSameName; + } + return true; + } + ), + color: Yup.string() + .required("ERR_QUEUE_INVALID_COLOR") + .test("Check-color", "ERR_QUEUE_INVALID_COLOR", async value => { + if (value) { + const colorTestRegex = /^#[0-9a-f]{3,6}$/i; + return colorTestRegex.test(value); + } + return true; + }) + .test( + "Check-color-exists", + "ERR_QUEUE_COLOR_ALREADY_EXISTS", + async value => { + if (value) { + const queueWithSameColor = await Queue.findOne({ + where: { color: value, id: { [Op.not]: queueId } } + }); + return !queueWithSameColor; + } + return true; + } + ) + }); + + try { + await queueSchema.validate({ color, name }); + } catch (err) { + throw new AppError(err.message); + } + + const queue = await ShowQueueService(queueId); + + await queue.update(queueData); + + return queue; +}; + +export default UpdateQueueService; diff --git a/backend/src/services/QuickAnswerService/CreateQuickAnswerService.ts b/backend/src/services/QuickAnswerService/CreateQuickAnswerService.ts new file mode 100644 index 0000000..80668e1 --- /dev/null +++ b/backend/src/services/QuickAnswerService/CreateQuickAnswerService.ts @@ -0,0 +1,26 @@ +import AppError from "../../errors/AppError"; +import QuickAnswer from "../../models/QuickAnswer"; + +interface Request { + shortcut: string; + message: string; +} + +const CreateQuickAnswerService = async ({ + shortcut, + message +}: Request): Promise => { + const nameExists = await QuickAnswer.findOne({ + where: { shortcut } + }); + + if (nameExists) { + throw new AppError("ERR__SHORTCUT_DUPLICATED"); + } + + const quickAnswer = await QuickAnswer.create({ shortcut, message }); + + return quickAnswer; +}; + +export default CreateQuickAnswerService; diff --git a/backend/src/services/QuickAnswerService/DeleteQuickAnswerService.ts b/backend/src/services/QuickAnswerService/DeleteQuickAnswerService.ts new file mode 100644 index 0000000..1cc21b2 --- /dev/null +++ b/backend/src/services/QuickAnswerService/DeleteQuickAnswerService.ts @@ -0,0 +1,16 @@ +import QuickAnswer from "../../models/QuickAnswer"; +import AppError from "../../errors/AppError"; + +const DeleteQuickAnswerService = async (id: string): Promise => { + const quickAnswer = await QuickAnswer.findOne({ + where: { id } + }); + + if (!quickAnswer) { + throw new AppError("ERR_NO_QUICK_ANSWER_FOUND", 404); + } + + await quickAnswer.destroy(); +}; + +export default DeleteQuickAnswerService; diff --git a/backend/src/services/QuickAnswerService/ListQuickAnswerService.ts b/backend/src/services/QuickAnswerService/ListQuickAnswerService.ts new file mode 100644 index 0000000..0ddcbcc --- /dev/null +++ b/backend/src/services/QuickAnswerService/ListQuickAnswerService.ts @@ -0,0 +1,45 @@ +import { Sequelize } from "sequelize"; +import QuickAnswer from "../../models/QuickAnswer"; + +interface Request { + searchParam?: string; + pageNumber?: string; +} + +interface Response { + quickAnswers: QuickAnswer[]; + count: number; + hasMore: boolean; +} + +const ListQuickAnswerService = async ({ + searchParam = "", + pageNumber = "1" +}: Request): Promise => { + const whereCondition = { + message: Sequelize.where( + Sequelize.fn("LOWER", Sequelize.col("message")), + "LIKE", + `%${searchParam.toLowerCase().trim()}%` + ) + }; + const limit = 20; + const offset = limit * (+pageNumber - 1); + + const { count, rows: quickAnswers } = await QuickAnswer.findAndCountAll({ + where: whereCondition, + limit, + offset, + order: [["message", "ASC"]] + }); + + const hasMore = count > offset + quickAnswers.length; + + return { + quickAnswers, + count, + hasMore + }; +}; + +export default ListQuickAnswerService; diff --git a/backend/src/services/QuickAnswerService/ShowQuickAnswerService.ts b/backend/src/services/QuickAnswerService/ShowQuickAnswerService.ts new file mode 100644 index 0000000..1ed3d2e --- /dev/null +++ b/backend/src/services/QuickAnswerService/ShowQuickAnswerService.ts @@ -0,0 +1,14 @@ +import QuickAnswer from "../../models/QuickAnswer"; +import AppError from "../../errors/AppError"; + +const ShowQuickAnswerService = async (id: string): Promise => { + const quickAnswer = await QuickAnswer.findByPk(id); + + if (!quickAnswer) { + throw new AppError("ERR_NO_QUICK_ANSWERS_FOUND", 404); + } + + return quickAnswer; +}; + +export default ShowQuickAnswerService; diff --git a/backend/src/services/QuickAnswerService/UpdateQuickAnswerService.ts b/backend/src/services/QuickAnswerService/UpdateQuickAnswerService.ts new file mode 100644 index 0000000..e50351b --- /dev/null +++ b/backend/src/services/QuickAnswerService/UpdateQuickAnswerService.ts @@ -0,0 +1,40 @@ +import QuickAnswer from "../../models/QuickAnswer"; +import AppError from "../../errors/AppError"; + +interface QuickAnswerData { + shortcut?: string; + message?: string; +} + +interface Request { + quickAnswerData: QuickAnswerData; + quickAnswerId: string; +} + +const UpdateQuickAnswerService = async ({ + quickAnswerData, + quickAnswerId +}: Request): Promise => { + const { shortcut, message } = quickAnswerData; + + const quickAnswer = await QuickAnswer.findOne({ + where: { id: quickAnswerId }, + attributes: ["id", "shortcut", "message"] + }); + + if (!quickAnswer) { + throw new AppError("ERR_NO_QUICK_ANSWERS_FOUND", 404); + } + await quickAnswer.update({ + shortcut, + message + }); + + await quickAnswer.reload({ + attributes: ["id", "shortcut", "message"] + }); + + return quickAnswer; +}; + +export default UpdateQuickAnswerService; diff --git a/backend/src/services/SettingServices/ListSettingsService.ts b/backend/src/services/SettingServices/ListSettingsService.ts new file mode 100644 index 0000000..c3e2a19 --- /dev/null +++ b/backend/src/services/SettingServices/ListSettingsService.ts @@ -0,0 +1,9 @@ +import Setting from "../../models/Setting"; + +const ListSettingsService = async (): Promise => { + const settings = await Setting.findAll(); + + return settings; +}; + +export default ListSettingsService; diff --git a/backend/src/services/SettingServices/UpdateSettingService.ts b/backend/src/services/SettingServices/UpdateSettingService.ts new file mode 100644 index 0000000..9c443a1 --- /dev/null +++ b/backend/src/services/SettingServices/UpdateSettingService.ts @@ -0,0 +1,26 @@ +import AppError from "../../errors/AppError"; +import Setting from "../../models/Setting"; + +interface Request { + key: string; + value: string; +} + +const UpdateSettingService = async ({ + key, + value +}: Request): Promise => { + const setting = await Setting.findOne({ + where: { key } + }); + + if (!setting) { + throw new AppError("ERR_NO_SETTING_FOUND", 404); + } + + await setting.update({ value }); + + return setting; +}; + +export default UpdateSettingService; diff --git a/backend/src/services/TicketServices/CreateTicketService.ts b/backend/src/services/TicketServices/CreateTicketService.ts new file mode 100644 index 0000000..4ea16cf --- /dev/null +++ b/backend/src/services/TicketServices/CreateTicketService.ts @@ -0,0 +1,40 @@ +import AppError from "../../errors/AppError"; +import CheckContactOpenTickets from "../../helpers/CheckContactOpenTickets"; +import GetDefaultWhatsApp from "../../helpers/GetDefaultWhatsApp"; +import Ticket from "../../models/Ticket"; +import ShowContactService from "../ContactServices/ShowContactService"; + +interface Request { + contactId: number; + status: string; + userId: number; +} + +const CreateTicketService = async ({ + contactId, + status, + userId +}: Request): Promise => { + const defaultWhatsapp = await GetDefaultWhatsApp(); + + await CheckContactOpenTickets(contactId); + + const { isGroup } = await ShowContactService(contactId); + + const { id }: Ticket = await defaultWhatsapp.$create("ticket", { + contactId, + status, + isGroup, + userId + }); + + const ticket = await Ticket.findByPk(id, { include: ["contact"] }); + + if (!ticket) { + throw new AppError("ERR_CREATING_TICKET"); + } + + return ticket; +}; + +export default CreateTicketService; diff --git a/backend/src/services/TicketServices/DeleteTicketService.ts b/backend/src/services/TicketServices/DeleteTicketService.ts new file mode 100644 index 0000000..279a913 --- /dev/null +++ b/backend/src/services/TicketServices/DeleteTicketService.ts @@ -0,0 +1,18 @@ +import Ticket from "../../models/Ticket"; +import AppError from "../../errors/AppError"; + +const DeleteTicketService = async (id: string): Promise => { + const ticket = await Ticket.findOne({ + where: { id } + }); + + if (!ticket) { + throw new AppError("ERR_NO_TICKET_FOUND", 404); + } + + await ticket.destroy(); + + return ticket; +}; + +export default DeleteTicketService; diff --git a/backend/src/services/TicketServices/FindOrCreateTicketService.ts b/backend/src/services/TicketServices/FindOrCreateTicketService.ts new file mode 100644 index 0000000..bf4c2b0 --- /dev/null +++ b/backend/src/services/TicketServices/FindOrCreateTicketService.ts @@ -0,0 +1,78 @@ +import { subHours } from "date-fns"; +import { Op } from "sequelize"; +import Contact from "../../models/Contact"; +import Ticket from "../../models/Ticket"; +import ShowTicketService from "./ShowTicketService"; + +const FindOrCreateTicketService = async ( + contact: Contact, + whatsappId: number, + unreadMessages: number, + groupContact?: Contact +): Promise => { + let ticket = await Ticket.findOne({ + where: { + status: { + [Op.or]: ["open", "pending"] + }, + contactId: groupContact ? groupContact.id : contact.id + } + }); + + if (ticket) { + await ticket.update({ unreadMessages }); + } + + if (!ticket && groupContact) { + ticket = await Ticket.findOne({ + where: { + contactId: groupContact.id + }, + order: [["updatedAt", "DESC"]] + }); + + if (ticket) { + await ticket.update({ + status: "pending", + userId: null, + unreadMessages + }); + } + } + + if (!ticket && !groupContact) { + ticket = await Ticket.findOne({ + where: { + updatedAt: { + [Op.between]: [+subHours(new Date(), 2), +new Date()] + }, + contactId: contact.id + }, + order: [["updatedAt", "DESC"]] + }); + + if (ticket) { + await ticket.update({ + status: "pending", + userId: null, + unreadMessages + }); + } + } + + if (!ticket) { + ticket = await Ticket.create({ + contactId: groupContact ? groupContact.id : contact.id, + status: "pending", + isGroup: !!groupContact, + unreadMessages, + whatsappId + }); + } + + ticket = await ShowTicketService(ticket.id); + + return ticket; +}; + +export default FindOrCreateTicketService; diff --git a/backend/src/services/TicketServices/ListTicketsService.ts b/backend/src/services/TicketServices/ListTicketsService.ts new file mode 100644 index 0000000..29e165c --- /dev/null +++ b/backend/src/services/TicketServices/ListTicketsService.ts @@ -0,0 +1,150 @@ +import { Op, fn, where, col, Filterable, Includeable } from "sequelize"; +import { startOfDay, endOfDay, parseISO } from "date-fns"; + +import Ticket from "../../models/Ticket"; +import Contact from "../../models/Contact"; +import Message from "../../models/Message"; +import Queue from "../../models/Queue"; +import ShowUserService from "../UserServices/ShowUserService"; + +interface Request { + searchParam?: string; + pageNumber?: string; + status?: string; + date?: string; + showAll?: string; + userId: string; + withUnreadMessages?: string; + queueIds: number[]; +} + +interface Response { + tickets: Ticket[]; + count: number; + hasMore: boolean; +} + +const ListTicketsService = async ({ + searchParam = "", + pageNumber = "1", + queueIds, + status, + date, + showAll, + userId, + withUnreadMessages +}: Request): Promise => { + let whereCondition: Filterable["where"] = { + [Op.or]: [{ userId }, { status: "pending" }], + queueId: { [Op.or]: [queueIds, null] } + }; + let includeCondition: Includeable[]; + + includeCondition = [ + { + model: Contact, + as: "contact", + attributes: ["id", "name", "number", "profilePicUrl"] + }, + { + model: Queue, + as: "queue", + attributes: ["id", "name", "color"] + } + ]; + + if (showAll === "true") { + whereCondition = { queueId: { [Op.or]: [queueIds, null] } }; + } + + if (status) { + whereCondition = { + ...whereCondition, + status + }; + } + + if (searchParam) { + const sanitizedSearchParam = searchParam.toLocaleLowerCase().trim(); + + includeCondition = [ + ...includeCondition, + { + model: Message, + as: "messages", + attributes: ["id", "body"], + where: { + body: where( + fn("LOWER", col("body")), + "LIKE", + `%${sanitizedSearchParam}%` + ) + }, + required: false, + duplicating: false + } + ]; + + whereCondition = { + ...whereCondition, + [Op.or]: [ + { + "$contact.name$": where( + fn("LOWER", col("contact.name")), + "LIKE", + `%${sanitizedSearchParam}%` + ) + }, + { "$contact.number$": { [Op.like]: `%${sanitizedSearchParam}%` } }, + { + "$message.body$": where( + fn("LOWER", col("body")), + "LIKE", + `%${sanitizedSearchParam}%` + ) + } + ] + }; + } + + if (date) { + whereCondition = { + createdAt: { + [Op.between]: [+startOfDay(parseISO(date)), +endOfDay(parseISO(date))] + } + }; + } + + if (withUnreadMessages === "true") { + const user = await ShowUserService(userId); + const userQueueIds = user.queues.map(queue => queue.id); + + whereCondition = { + [Op.or]: [{ userId }, { status: "pending" }], + queueId: { [Op.or]: [userQueueIds, null] }, + unreadMessages: { [Op.gt]: 0 } + }; + } + + const limit = 40; + const offset = limit * (+pageNumber - 1); + + const { count, rows: tickets } = await Ticket.findAndCountAll({ + where: whereCondition, + include: includeCondition, + distinct: true, + limit, + offset, + order: [["updatedAt", "DESC"]] + }); + + const hasMore = count > offset + tickets.length; + + return { + tickets, + count, + hasMore + }; +}; + +export default ListTicketsService; diff --git a/backend/src/services/TicketServices/ShowTicketService.ts b/backend/src/services/TicketServices/ShowTicketService.ts new file mode 100644 index 0000000..5efab0c --- /dev/null +++ b/backend/src/services/TicketServices/ShowTicketService.ts @@ -0,0 +1,36 @@ +import Ticket from "../../models/Ticket"; +import AppError from "../../errors/AppError"; +import Contact from "../../models/Contact"; +import User from "../../models/User"; +import Queue from "../../models/Queue"; + +const ShowTicketService = async (id: string | number): Promise => { + const ticket = await Ticket.findByPk(id, { + include: [ + { + model: Contact, + as: "contact", + attributes: ["id", "name", "number", "profilePicUrl"], + include: ["extraInfo"] + }, + { + model: User, + as: "user", + attributes: ["id", "name"] + }, + { + model: Queue, + as: "queue", + attributes: ["id", "name", "color"] + } + ] + }); + + if (!ticket) { + throw new AppError("ERR_NO_TICKET_FOUND", 404); + } + + return ticket; +}; + +export default ShowTicketService; diff --git a/backend/src/services/TicketServices/UpdateTicketService.ts b/backend/src/services/TicketServices/UpdateTicketService.ts new file mode 100644 index 0000000..3efc5db --- /dev/null +++ b/backend/src/services/TicketServices/UpdateTicketService.ts @@ -0,0 +1,74 @@ +import CheckContactOpenTickets from "../../helpers/CheckContactOpenTickets"; +import SetTicketMessagesAsRead from "../../helpers/SetTicketMessagesAsRead"; +import { getIO } from "../../libs/socket"; +import Ticket from "../../models/Ticket"; +import SendWhatsAppMessage from "../WbotServices/SendWhatsAppMessage"; +import ShowWhatsAppService from "../WhatsappService/ShowWhatsAppService"; +import ShowTicketService from "./ShowTicketService"; + +interface TicketData { + status?: string; + userId?: number; + queueId?: number; +} + +interface Request { + ticketData: TicketData; + ticketId: string | number; +} + +interface Response { + ticket: Ticket; + oldStatus: string; + oldUserId: number | undefined; +} + +const UpdateTicketService = async ({ + ticketData, + ticketId +}: Request): Promise => { + const { status, userId, queueId } = ticketData; + + const ticket = await ShowTicketService(ticketId); + await SetTicketMessagesAsRead(ticket); + + const oldStatus = ticket.status; + const oldUserId = ticket.user?.id; + + if (oldStatus === "closed") { + await CheckContactOpenTickets(ticket.contact.id); + } + + await ticket.update({ + status, + queueId, + userId + }); + + + + await ticket.reload(); + + const io = getIO(); + + if (ticket.status !== oldStatus || ticket.user?.id !== oldUserId) { + io.to(oldStatus).emit("ticket", { + action: "delete", + ticketId: ticket.id + }); + } + + + + io.to(ticket.status) + .to("notification") + .to(ticketId.toString()) + .emit("ticket", { + action: "update", + ticket + }); + + return { ticket, oldStatus, oldUserId }; +}; + +export default UpdateTicketService; diff --git a/backend/src/services/UserServices/AuthUserService.ts b/backend/src/services/UserServices/AuthUserService.ts new file mode 100644 index 0000000..f198a7c --- /dev/null +++ b/backend/src/services/UserServices/AuthUserService.ts @@ -0,0 +1,58 @@ +import User from "../../models/User"; +import AppError from "../../errors/AppError"; +import { + createAccessToken, + createRefreshToken +} from "../../helpers/CreateTokens"; +import { SerializeUser } from "../../helpers/SerializeUser"; +import Queue from "../../models/Queue"; + +interface SerializedUser { + id: number; + name: string; + email: string; + profile: string; + queues: Queue[]; +} + +interface Request { + email: string; + password: string; +} + +interface Response { + serializedUser: SerializedUser; + token: string; + refreshToken: string; +} + +const AuthUserService = async ({ + email, + password +}: Request): Promise => { + const user = await User.findOne({ + where: { email }, + include: ["queues"] + }); + + if (!user) { + throw new AppError("ERR_INVALID_CREDENTIALS", 401); + } + + if (!(await user.checkPassword(password))) { + throw new AppError("ERR_INVALID_CREDENTIALS", 401); + } + + const token = createAccessToken(user); + const refreshToken = createRefreshToken(user); + + const serializedUser = SerializeUser(user); + + return { + serializedUser, + token, + refreshToken + }; +}; + +export default AuthUserService; diff --git a/backend/src/services/UserServices/CreateUserService.ts b/backend/src/services/UserServices/CreateUserService.ts new file mode 100644 index 0000000..098846b --- /dev/null +++ b/backend/src/services/UserServices/CreateUserService.ts @@ -0,0 +1,73 @@ +import * as Yup from "yup"; + +import AppError from "../../errors/AppError"; +import { SerializeUser } from "../../helpers/SerializeUser"; +import User from "../../models/User"; + +interface Request { + email: string; + password: string; + name: string; + queueIds?: number[]; + profile?: string; +} + +interface Response { + email: string; + name: string; + id: number; + profile: string; +} + +const CreateUserService = async ({ + email, + password, + name, + queueIds = [], + profile = "admin" +}: Request): Promise => { + 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 => { + if (!value) return false; + const emailExists = await User.findOne({ + where: { email: value } + }); + return !emailExists; + } + ), + password: Yup.string().required().min(5) + }); + + try { + await schema.validate({ email, password, name }); + } catch (err) { + throw new AppError(err.message); + } + + const user = await User.create( + { + email, + password, + name, + profile + }, + { include: ["queues"] } + ); + + await user.$set("queues", queueIds); + + await user.reload(); + + const serializedUser = SerializeUser(user); + + return serializedUser; +}; + +export default CreateUserService; diff --git a/backend/src/services/UserServices/DeleteUserService.ts b/backend/src/services/UserServices/DeleteUserService.ts new file mode 100644 index 0000000..ffaf5f0 --- /dev/null +++ b/backend/src/services/UserServices/DeleteUserService.ts @@ -0,0 +1,26 @@ +import User from "../../models/User"; +import AppError from "../../errors/AppError"; +import Ticket from "../../models/Ticket"; +import UpdateDeletedUserOpenTicketsStatus from "../../helpers/UpdateDeletedUserOpenTicketsStatus"; + +const DeleteUserService = async (id: string | number): Promise => { + const user = await User.findOne({ + where: { id } + }); + + if (!user) { + throw new AppError("ERR_NO_USER_FOUND", 404); + } + + const userOpenTickets: Ticket[] = await user.$get("tickets", { + where: { status: "open" } + }); + + if (userOpenTickets.length > 0) { + UpdateDeletedUserOpenTicketsStatus(userOpenTickets); + } + + await user.destroy(); +}; + +export default DeleteUserService; diff --git a/backend/src/services/UserServices/ListUsersService.ts b/backend/src/services/UserServices/ListUsersService.ts new file mode 100644 index 0000000..fb8a202 --- /dev/null +++ b/backend/src/services/UserServices/ListUsersService.ts @@ -0,0 +1,55 @@ +import { Sequelize, Op } from "sequelize"; +import Queue from "../../models/Queue"; +import User from "../../models/User"; + +interface Request { + searchParam?: string; + pageNumber?: string | number; +} + +interface Response { + users: User[]; + count: number; + hasMore: boolean; +} + +const ListUsersService = async ({ + searchParam = "", + pageNumber = "1" +}: Request): Promise => { + const whereCondition = { + [Op.or]: [ + { + "$User.name$": Sequelize.where( + Sequelize.fn("LOWER", Sequelize.col("User.name")), + "LIKE", + `%${searchParam.toLowerCase()}%` + ) + }, + { email: { [Op.like]: `%${searchParam.toLowerCase()}%` } } + ] + }; + const limit = 20; + const offset = limit * (+pageNumber - 1); + + const { count, rows: users } = await User.findAndCountAll({ + where: whereCondition, + attributes: ["name", "id", "email", "profile", "createdAt"], + limit, + offset, + order: [["createdAt", "DESC"]], + include: [ + { model: Queue, as: "queues", attributes: ["id", "name", "color"] } + ] + }); + + const hasMore = count > offset + users.length; + + return { + users, + count, + hasMore + }; +}; + +export default ListUsersService; diff --git a/backend/src/services/UserServices/ShowUserService.ts b/backend/src/services/UserServices/ShowUserService.ts new file mode 100644 index 0000000..03ecab4 --- /dev/null +++ b/backend/src/services/UserServices/ShowUserService.ts @@ -0,0 +1,20 @@ +import User from "../../models/User"; +import AppError from "../../errors/AppError"; +import Queue from "../../models/Queue"; + +const ShowUserService = async (id: string | number): Promise => { + const user = await User.findByPk(id, { + attributes: ["name", "id", "email", "profile", "tokenVersion"], + include: [ + { model: Queue, as: "queues", attributes: ["id", "name", "color"] } + ], + order: [ [ { model: Queue, as: "queues"}, 'name', 'asc' ] ] + }); + if (!user) { + throw new AppError("ERR_NO_USER_FOUND", 404); + } + + return user; +}; + +export default ShowUserService; diff --git a/backend/src/services/UserServices/UpdateUserService.ts b/backend/src/services/UserServices/UpdateUserService.ts new file mode 100644 index 0000000..114d557 --- /dev/null +++ b/backend/src/services/UserServices/UpdateUserService.ts @@ -0,0 +1,69 @@ +import * as Yup from "yup"; + +import AppError from "../../errors/AppError"; +import ShowUserService from "./ShowUserService"; + +interface UserData { + email?: string; + password?: string; + name?: string; + profile?: string; + queueIds?: number[]; +} + +interface Request { + userData: UserData; + userId: string | number; +} + +interface Response { + id: number; + name: string; + email: string; + profile: string; +} + +const UpdateUserService = async ({ + userData, + userId +}: Request): Promise => { + const user = await ShowUserService(userId); + + const schema = Yup.object().shape({ + name: Yup.string().min(2), + email: Yup.string().email(), + profile: Yup.string(), + password: Yup.string() + }); + + const { email, password, profile, name, queueIds = [] } = userData; + + try { + await schema.validate({ email, password, profile, name }); + } catch (err) { + throw new AppError(err.message); + } + + await user.update({ + email, + password, + profile, + name + }); + + await user.$set("queues", queueIds); + + await user.reload(); + + const serializedUser = { + id: user.id, + name: user.name, + email: user.email, + profile: user.profile, + queues: user.queues + }; + + return serializedUser; +}; + +export default UpdateUserService; diff --git a/backend/src/services/WbotServices/CheckIsValidContact.ts b/backend/src/services/WbotServices/CheckIsValidContact.ts new file mode 100644 index 0000000..daa56bf --- /dev/null +++ b/backend/src/services/WbotServices/CheckIsValidContact.ts @@ -0,0 +1,23 @@ +import AppError from "../../errors/AppError"; +import GetDefaultWhatsApp from "../../helpers/GetDefaultWhatsApp"; +import { getWbot } from "../../libs/wbot"; + +const CheckIsValidContact = async (number: string): Promise => { + const defaultWhatsapp = await GetDefaultWhatsApp(); + + const wbot = getWbot(defaultWhatsapp.id); + + try { + const isValidNumber = await wbot.isRegisteredUser(`${number}@c.us`); + if (!isValidNumber) { + throw new AppError("invalidNumber"); + } + } catch (err) { + if (err.message === "invalidNumber") { + throw new AppError("ERR_WAPP_INVALID_CONTACT"); + } + throw new AppError("ERR_WAPP_CHECK_CONTACT"); + } +}; + +export default CheckIsValidContact; diff --git a/backend/src/services/WbotServices/CheckNumber.ts b/backend/src/services/WbotServices/CheckNumber.ts new file mode 100644 index 0000000..80753d6 --- /dev/null +++ b/backend/src/services/WbotServices/CheckNumber.ts @@ -0,0 +1,13 @@ +import GetDefaultWhatsApp from "../../helpers/GetDefaultWhatsApp"; +import { getWbot } from "../../libs/wbot"; + +const CheckContactNumber = async (number: string): Promise => { + const defaultWhatsapp = await GetDefaultWhatsApp(); + + const wbot = getWbot(defaultWhatsapp.id); + + const validNumber : any = await wbot.getNumberId(`${number}@c.us`); + return validNumber.user +}; + +export default CheckContactNumber; diff --git a/backend/src/services/WbotServices/DeleteWhatsAppMessage.ts b/backend/src/services/WbotServices/DeleteWhatsAppMessage.ts new file mode 100644 index 0000000..b9b3b17 --- /dev/null +++ b/backend/src/services/WbotServices/DeleteWhatsAppMessage.ts @@ -0,0 +1,36 @@ +import AppError from "../../errors/AppError"; +import GetWbotMessage from "../../helpers/GetWbotMessage"; +import Message from "../../models/Message"; +import Ticket from "../../models/Ticket"; + +const DeleteWhatsAppMessage = async (messageId: string): Promise => { + const message = await Message.findByPk(messageId, { + include: [ + { + model: Ticket, + as: "ticket", + include: ["contact"] + } + ] + }); + + if (!message) { + throw new AppError("No message found with this ID."); + } + + const { ticket } = message; + + const messageToDelete = await GetWbotMessage(ticket, messageId); + + try { + await messageToDelete.delete(true); + } catch (err) { + throw new AppError("ERR_DELETE_WAPP_MSG"); + } + + await message.update({ isDeleted: true }); + + return message; +}; + +export default DeleteWhatsAppMessage; diff --git a/backend/src/services/WbotServices/GetProfilePicUrl.ts b/backend/src/services/WbotServices/GetProfilePicUrl.ts new file mode 100644 index 0000000..6e5829a --- /dev/null +++ b/backend/src/services/WbotServices/GetProfilePicUrl.ts @@ -0,0 +1,14 @@ +import GetDefaultWhatsApp from "../../helpers/GetDefaultWhatsApp"; +import { getWbot } from "../../libs/wbot"; + +const GetProfilePicUrl = async (number: string): Promise => { + const defaultWhatsapp = await GetDefaultWhatsApp(); + + const wbot = getWbot(defaultWhatsapp.id); + + const profilePicUrl = await wbot.getProfilePicUrl(`${number}@c.us`); + + return profilePicUrl; +}; + +export default GetProfilePicUrl; diff --git a/backend/src/services/WbotServices/ImportContactsService.ts b/backend/src/services/WbotServices/ImportContactsService.ts new file mode 100644 index 0000000..ee6fd1f --- /dev/null +++ b/backend/src/services/WbotServices/ImportContactsService.ts @@ -0,0 +1,41 @@ +import GetDefaultWhatsApp from "../../helpers/GetDefaultWhatsApp"; +import { getWbot } from "../../libs/wbot"; +import Contact from "../../models/Contact"; +import { logger } from "../../utils/logger"; + +const ImportContactsService = async (): Promise => { + const defaultWhatsapp = await GetDefaultWhatsApp(); + + const wbot = getWbot(defaultWhatsapp.id); + + let phoneContacts; + + try { + phoneContacts = await wbot.getContacts(); + } catch (err) { + logger.error(`Could not get whatsapp contacts from phone. Err: ${err}`); + } + + if (phoneContacts) { + await Promise.all( + phoneContacts.map(async ({ number, name }) => { + if (!number) { + return null; + } + if (!name) { + name = number; + } + + const numberExists = await Contact.findOne({ + where: { number } + }); + + if (numberExists) return null; + + return Contact.create({ number, name }); + }) + ); + } +}; + +export default ImportContactsService; diff --git a/backend/src/services/WbotServices/SendWhatsAppMedia.ts b/backend/src/services/WbotServices/SendWhatsAppMedia.ts new file mode 100644 index 0000000..455d5c3 --- /dev/null +++ b/backend/src/services/WbotServices/SendWhatsAppMedia.ts @@ -0,0 +1,37 @@ +import fs from "fs"; +import { MessageMedia, Message as WbotMessage } from "whatsapp-web.js"; +import AppError from "../../errors/AppError"; +import GetTicketWbot from "../../helpers/GetTicketWbot"; +import Ticket from "../../models/Ticket"; + +interface Request { + media: Express.Multer.File; + ticket: Ticket; +} + +const SendWhatsAppMedia = async ({ + media, + ticket +}: Request): Promise => { + try { + const wbot = await GetTicketWbot(ticket); + + const newMedia = MessageMedia.fromFilePath(media.path); + + const sentMessage = await wbot.sendMessage( + `${ticket.contact.number}@${ticket.isGroup ? "g" : "c"}.us`, + newMedia, + { sendAudioAsVoice: true } + ); + + await ticket.update({ lastMessage: media.filename }); + + fs.unlinkSync(media.path); + + return sentMessage; + } catch (err) { + throw new AppError("ERR_SENDING_WAPP_MSG"); + } +}; + +export default SendWhatsAppMedia; diff --git a/backend/src/services/WbotServices/SendWhatsAppMessage.ts b/backend/src/services/WbotServices/SendWhatsAppMessage.ts new file mode 100644 index 0000000..f2ef571 --- /dev/null +++ b/backend/src/services/WbotServices/SendWhatsAppMessage.ts @@ -0,0 +1,45 @@ +import { Message as WbotMessage } from "whatsapp-web.js"; +import AppError from "../../errors/AppError"; +import GetTicketWbot from "../../helpers/GetTicketWbot"; +import GetWbotMessage from "../../helpers/GetWbotMessage"; +import SerializeWbotMsgId from "../../helpers/SerializeWbotMsgId"; +import Message from "../../models/Message"; +import Ticket from "../../models/Ticket"; + +interface Request { + body: string; + ticket: Ticket; + quotedMsg?: Message; +} + +const SendWhatsAppMessage = async ({ + body, + ticket, + quotedMsg +}: Request): Promise => { + let quotedMsgSerializedId: string | undefined; + if (quotedMsg) { + await GetWbotMessage(ticket, quotedMsg.id); + quotedMsgSerializedId = SerializeWbotMsgId(ticket, quotedMsg); + } + + const wbot = await GetTicketWbot(ticket); + + try { + const sentMessage = await wbot.sendMessage( + `${ticket.contact.number}@${ticket.isGroup ? "g" : "c"}.us`, + body, + { + quotedMessageId: quotedMsgSerializedId, + linkPreview: false + } + ); + + await ticket.update({ lastMessage: body }); + return sentMessage; + } catch (err) { + throw new AppError("ERR_SENDING_WAPP_MSG"); + } +}; + +export default SendWhatsAppMessage; diff --git a/backend/src/services/WbotServices/StartAllWhatsAppsSessions.ts b/backend/src/services/WbotServices/StartAllWhatsAppsSessions.ts new file mode 100644 index 0000000..9e5e935 --- /dev/null +++ b/backend/src/services/WbotServices/StartAllWhatsAppsSessions.ts @@ -0,0 +1,11 @@ +import ListWhatsAppsService from "../WhatsappService/ListWhatsAppsService"; +import { StartWhatsAppSession } from "./StartWhatsAppSession"; + +export const StartAllWhatsAppsSessions = async (): Promise => { + const whatsapps = await ListWhatsAppsService(); + if (whatsapps.length > 0) { + whatsapps.forEach(whatsapp => { + StartWhatsAppSession(whatsapp); + }); + } +}; diff --git a/backend/src/services/WbotServices/StartWhatsAppSession.ts b/backend/src/services/WbotServices/StartWhatsAppSession.ts new file mode 100644 index 0000000..074f5fa --- /dev/null +++ b/backend/src/services/WbotServices/StartWhatsAppSession.ts @@ -0,0 +1,26 @@ +import { initWbot } from "../../libs/wbot"; +import Whatsapp from "../../models/Whatsapp"; +import { wbotMessageListener } from "./wbotMessageListener"; +import { getIO } from "../../libs/socket"; +import wbotMonitor from "./wbotMonitor"; +import { logger } from "../../utils/logger"; + +export const StartWhatsAppSession = async ( + whatsapp: Whatsapp +): Promise => { + await whatsapp.update({ status: "OPENING" }); + + const io = getIO(); + io.emit("whatsappSession", { + action: "update", + session: whatsapp + }); + + try { + const wbot = await initWbot(whatsapp); + wbotMessageListener(wbot); + wbotMonitor(wbot, whatsapp); + } catch (err) { + logger.error(err); + } +}; diff --git a/backend/src/services/WbotServices/wbotMessageListener.ts b/backend/src/services/WbotServices/wbotMessageListener.ts new file mode 100644 index 0000000..c443ebc --- /dev/null +++ b/backend/src/services/WbotServices/wbotMessageListener.ts @@ -0,0 +1,330 @@ +import { join } from "path"; +import { promisify } from "util"; +import { writeFile } from "fs"; +import * as Sentry from "@sentry/node"; + +import { + Contact as WbotContact, + Message as WbotMessage, + MessageAck, + Client +} from "whatsapp-web.js"; + +import Contact from "../../models/Contact"; +import Ticket from "../../models/Ticket"; +import Message from "../../models/Message"; + +import { getIO } from "../../libs/socket"; +import CreateMessageService from "../MessageServices/CreateMessageService"; +import { logger } from "../../utils/logger"; +import CreateOrUpdateContactService from "../ContactServices/CreateOrUpdateContactService"; +import FindOrCreateTicketService from "../TicketServices/FindOrCreateTicketService"; +import ShowWhatsAppService from "../WhatsappService/ShowWhatsAppService"; +import { debounce } from "../../helpers/Debounce"; +import UpdateTicketService from "../TicketServices/UpdateTicketService"; + +interface Session extends Client { + id?: number; +} + +const writeFileAsync = promisify(writeFile); + +const verifyContact = async (msgContact: WbotContact): Promise => { + const profilePicUrl = await msgContact.getProfilePicUrl(); + + const contactData = { + name: msgContact.name || msgContact.pushname || msgContact.id.user, + number: msgContact.id.user, + profilePicUrl, + isGroup: msgContact.isGroup + }; + + const contact = CreateOrUpdateContactService(contactData); + + return contact; +}; + +const verifyQuotedMessage = async ( + msg: WbotMessage +): Promise => { + if (!msg.hasQuotedMsg) return null; + + const wbotQuotedMsg = await msg.getQuotedMessage(); + + const quotedMsg = await Message.findOne({ + where: { id: wbotQuotedMsg.id.id } + }); + + if (!quotedMsg) return null; + + return quotedMsg; +}; + +const verifyMediaMessage = async ( + msg: WbotMessage, + ticket: Ticket, + contact: Contact +): Promise => { + const quotedMsg = await verifyQuotedMessage(msg); + + const media = await msg.downloadMedia(); + + if (!media) { + throw new Error("ERR_WAPP_DOWNLOAD_MEDIA"); + } + + if (!media.filename) { + const ext = media.mimetype.split("/")[1].split(";")[0]; + media.filename = `${new Date().getTime()}.${ext}`; + } + + try { + await writeFileAsync( + join(__dirname, "..", "..", "..", "public", media.filename), + media.data, + "base64" + ); + } catch (err) { + Sentry.captureException(err); + logger.error(err); + } + + const messageData = { + id: msg.id.id, + ticketId: ticket.id, + contactId: msg.fromMe ? undefined : contact.id, + body: msg.body || media.filename, + fromMe: msg.fromMe, + read: msg.fromMe, + mediaUrl: media.filename, + mediaType: media.mimetype.split("/")[0], + quotedMsgId: quotedMsg?.id + }; + + await ticket.update({ lastMessage: msg.body || media.filename }); + const newMessage = await CreateMessageService({ messageData }); + + return newMessage; +}; + +const verifyMessage = async ( + msg: WbotMessage, + ticket: Ticket, + contact: Contact +) => { + const quotedMsg = await verifyQuotedMessage(msg); + const messageData = { + id: msg.id.id, + ticketId: ticket.id, + contactId: msg.fromMe ? undefined : contact.id, + body: msg.body, + fromMe: msg.fromMe, + mediaType: msg.type, + read: msg.fromMe, + quotedMsgId: quotedMsg?.id + }; + + await ticket.update({ lastMessage: msg.body }); + + await CreateMessageService({ messageData }); +}; + +const verifyQueue = async ( + wbot: Session, + msg: WbotMessage, + ticket: Ticket, + contact: Contact +) => { + const { queues, greetingMessage } = await ShowWhatsAppService(wbot.id!); + + if (queues.length === 1) { + await UpdateTicketService({ + ticketData: { queueId: queues[0].id }, + ticketId: ticket.id + }); + + return; + } + + const selectedOption = msg.body; + + const choosenQueue = queues[+selectedOption - 1]; + + if (choosenQueue) { + await UpdateTicketService({ + ticketData: { queueId: choosenQueue.id }, + ticketId: ticket.id + }); + + const body = `\u200e${choosenQueue.greetingMessage}`; + + const sentMessage = await wbot.sendMessage(`${contact.number}@c.us`, body); + + await verifyMessage(sentMessage, ticket, contact); + } else { + let options = ""; + + queues.forEach((queue, index) => { + options += `*${index + 1}* - ${queue.name}\n`; + }); + + const body = `\u200e${greetingMessage}\n${options}`; + + const debouncedSentMessage = debounce( + async () => { + const sentMessage = await wbot.sendMessage( + `${contact.number}@c.us`, + body + ); + verifyMessage(sentMessage, ticket, contact); + }, + 3000, + ticket.id + ); + + debouncedSentMessage(); + } +}; + +const isValidMsg = (msg: WbotMessage): boolean => { + if (msg.from === "status@broadcast") return false; + if ( + msg.type === "chat" || + msg.type === "audio" || + msg.type === "ptt" || + msg.type === "video" || + msg.type === "image" || + msg.type === "document" || + msg.type === "vcard" || + msg.type === "sticker" + ) + return true; + return false; +}; + +const handleMessage = async ( + msg: WbotMessage, + wbot: Session +): Promise => { + if (!isValidMsg(msg)) { + return; + } + + try { + let msgContact: WbotContact; + let groupContact: Contact | undefined; + + if (msg.fromMe) { + // messages sent automatically by wbot have a special character in front of it + // if so, this message was already been stored in database; + if (/\u200e/.test(msg.body[0])) return; + + // media messages sent from me from cell phone, first comes with "hasMedia = false" and type = "image/ptt/etc" + // in this case, return and let this message be handled by "media_uploaded" event, when it will have "hasMedia = true" + + if (!msg.hasMedia && msg.type !== "chat" && msg.type !== "vcard") return; + + msgContact = await wbot.getContactById(msg.to); + } else { + msgContact = await msg.getContact(); + } + + const chat = await msg.getChat(); + + + if (chat.isGroup) { + let msgGroupContact; + + if (msg.fromMe) { + msgGroupContact = await wbot.getContactById(msg.to); + } else { + msgGroupContact = await wbot.getContactById(msg.from); + } + + groupContact = await verifyContact(msgGroupContact); + } + const whatsapp = await ShowWhatsAppService(wbot.id!); + + const unreadMessages = msg.fromMe ? 0 : chat.unreadCount; + + const contact = await verifyContact(msgContact); + + if ( unreadMessages === 0 && whatsapp.farewellMessage && whatsapp.farewellMessage === msg.body) return; + + const ticket = await FindOrCreateTicketService( + contact, + wbot.id!, + unreadMessages, + groupContact + ); + + if (msg.hasMedia) { + await verifyMediaMessage(msg, ticket, contact); + } else { + await verifyMessage(msg, ticket, contact); + } + + if ( + !ticket.queue && + !chat.isGroup && + !msg.fromMe && + !ticket.userId && + whatsapp.queues.length >= 1 + ) { + await verifyQueue(wbot, msg, ticket, contact); + } + + + + } catch (err) { + Sentry.captureException(err); + logger.error(`Error handling whatsapp message: Err: ${err}`); + } +}; + +const handleMsgAck = async (msg: WbotMessage, ack: MessageAck) => { + await new Promise(r => setTimeout(r, 500)); + + const io = getIO(); + + try { + const messageToUpdate = await Message.findByPk(msg.id.id, { + include: [ + "contact", + { + model: Message, + as: "quotedMsg", + include: ["contact"] + } + ] + }); + if (!messageToUpdate) { + return; + } + await messageToUpdate.update({ ack }); + + io.to(messageToUpdate.ticketId.toString()).emit("appMessage", { + action: "update", + message: messageToUpdate + }); + } catch (err) { + Sentry.captureException(err); + logger.error(`Error handling message ack. Err: ${err}`); + } +}; + +const wbotMessageListener = (wbot: Session): void => { + wbot.on("message_create", async msg => { + handleMessage(msg, wbot); + }); + + wbot.on("media_uploaded", async msg => { + handleMessage(msg, wbot); + }); + + wbot.on("message_ack", async (msg, ack) => { + handleMsgAck(msg, ack); + }); +}; + +export { wbotMessageListener, handleMessage }; diff --git a/backend/src/services/WbotServices/wbotMonitor.ts b/backend/src/services/WbotServices/wbotMonitor.ts new file mode 100644 index 0000000..3989fe8 --- /dev/null +++ b/backend/src/services/WbotServices/wbotMonitor.ts @@ -0,0 +1,77 @@ +import * as Sentry from "@sentry/node"; +import { Client } from "whatsapp-web.js"; + +import { getIO } from "../../libs/socket"; +import Whatsapp from "../../models/Whatsapp"; +import { logger } from "../../utils/logger"; +import { StartWhatsAppSession } from "./StartWhatsAppSession"; + +interface Session extends Client { + id?: number; +} + +const wbotMonitor = async ( + wbot: Session, + whatsapp: Whatsapp +): Promise => { + const io = getIO(); + const sessionName = whatsapp.name; + + try { + wbot.on("change_state", async newState => { + logger.info(`Monitor session: ${sessionName}, ${newState}`); + try { + await whatsapp.update({ status: newState }); + } catch (err) { + Sentry.captureException(err); + logger.error(err); + } + + io.emit("whatsappSession", { + action: "update", + session: whatsapp + }); + }); + + wbot.on("change_battery", async batteryInfo => { + const { battery, plugged } = batteryInfo; + logger.info( + `Battery session: ${sessionName} ${battery}% - Charging? ${plugged}` + ); + + try { + await whatsapp.update({ battery, plugged }); + } catch (err) { + Sentry.captureException(err); + logger.error(err); + } + + io.emit("whatsappSession", { + action: "update", + session: whatsapp + }); + }); + + wbot.on("disconnected", async reason => { + logger.info(`Disconnected session: ${sessionName}, reason: ${reason}`); + try { + await whatsapp.update({ status: "OPENING", session: "" }); + } catch (err) { + Sentry.captureException(err); + logger.error(err); + } + + io.emit("whatsappSession", { + action: "update", + session: whatsapp + }); + + setTimeout(() => StartWhatsAppSession(whatsapp), 2000); + }); + } catch (err) { + Sentry.captureException(err); + logger.error(err); + } +}; + +export default wbotMonitor; diff --git a/backend/src/services/WhatsappService/AssociateWhatsappQueue.ts b/backend/src/services/WhatsappService/AssociateWhatsappQueue.ts new file mode 100644 index 0000000..5f840f7 --- /dev/null +++ b/backend/src/services/WhatsappService/AssociateWhatsappQueue.ts @@ -0,0 +1,12 @@ +import Whatsapp from "../../models/Whatsapp"; + +const AssociateWhatsappQueue = async ( + whatsapp: Whatsapp, + queueIds: number[] +): Promise => { + await whatsapp.$set("queues", queueIds); + + await whatsapp.reload(); +}; + +export default AssociateWhatsappQueue; diff --git a/backend/src/services/WhatsappService/CreateWhatsAppService.ts b/backend/src/services/WhatsappService/CreateWhatsAppService.ts new file mode 100644 index 0000000..b9f8d37 --- /dev/null +++ b/backend/src/services/WhatsappService/CreateWhatsAppService.ts @@ -0,0 +1,88 @@ +import * as Yup from "yup"; + +import AppError from "../../errors/AppError"; +import Whatsapp from "../../models/Whatsapp"; +import AssociateWhatsappQueue from "./AssociateWhatsappQueue"; + +interface Request { + name: string; + queueIds?: number[]; + greetingMessage?: string; + farewellMessage?: string; + status?: string; + isDefault?: boolean; +} + +interface Response { + whatsapp: Whatsapp; + oldDefaultWhatsapp: Whatsapp | null; +} + +const CreateWhatsAppService = async ({ + name, + status = "OPENING", + queueIds = [], + greetingMessage, + farewellMessage, + isDefault = false +}: Request): Promise => { + const schema = Yup.object().shape({ + name: Yup.string() + .required() + .min(2) + .test( + "Check-name", + "This whatsapp name is already used.", + async value => { + if (!value) return false; + const nameExists = await Whatsapp.findOne({ + where: { name: value } + }); + return !nameExists; + } + ), + isDefault: Yup.boolean().required() + }); + + try { + await schema.validate({ name, status, isDefault }); + } catch (err) { + throw new AppError(err.message); + } + + const whatsappFound = await Whatsapp.findOne(); + + isDefault = !whatsappFound; + + let oldDefaultWhatsapp: Whatsapp | null = null; + + if (isDefault) { + oldDefaultWhatsapp = await Whatsapp.findOne({ + where: { isDefault: true } + }); + if (oldDefaultWhatsapp) { + await oldDefaultWhatsapp.update({ isDefault: false }); + } + } + + if (queueIds.length > 1 && !greetingMessage) { + throw new AppError("ERR_WAPP_GREETING_REQUIRED"); + } + + const whatsapp = await Whatsapp.create( + { + name, + status, + greetingMessage, + farewellMessage, + isDefault + }, + { include: ["queues"] } + ); + + await AssociateWhatsappQueue(whatsapp, queueIds); + + return { whatsapp, oldDefaultWhatsapp }; +}; + +export default CreateWhatsAppService; diff --git a/backend/src/services/WhatsappService/DeleteWhatsAppService.ts b/backend/src/services/WhatsappService/DeleteWhatsAppService.ts new file mode 100644 index 0000000..ff516b8 --- /dev/null +++ b/backend/src/services/WhatsappService/DeleteWhatsAppService.ts @@ -0,0 +1,16 @@ +import Whatsapp from "../../models/Whatsapp"; +import AppError from "../../errors/AppError"; + +const DeleteWhatsAppService = async (id: string): Promise => { + const whatsapp = await Whatsapp.findOne({ + where: { id } + }); + + if (!whatsapp) { + throw new AppError("ERR_NO_WAPP_FOUND", 404); + } + + await whatsapp.destroy(); +}; + +export default DeleteWhatsAppService; diff --git a/backend/src/services/WhatsappService/ListWhatsAppsService.ts b/backend/src/services/WhatsappService/ListWhatsAppsService.ts new file mode 100644 index 0000000..3d29c2c --- /dev/null +++ b/backend/src/services/WhatsappService/ListWhatsAppsService.ts @@ -0,0 +1,18 @@ +import Queue from "../../models/Queue"; +import Whatsapp from "../../models/Whatsapp"; + +const ListWhatsAppsService = async (): Promise => { + const whatsapps = await Whatsapp.findAll({ + include: [ + { + model: Queue, + as: "queues", + attributes: ["id", "name", "color", "greetingMessage"] + } + ] + }); + + return whatsapps; +}; + +export default ListWhatsAppsService; diff --git a/backend/src/services/WhatsappService/ShowWhatsAppService.ts b/backend/src/services/WhatsappService/ShowWhatsAppService.ts new file mode 100644 index 0000000..235ef17 --- /dev/null +++ b/backend/src/services/WhatsappService/ShowWhatsAppService.ts @@ -0,0 +1,24 @@ +import Whatsapp from "../../models/Whatsapp"; +import AppError from "../../errors/AppError"; +import Queue from "../../models/Queue"; + +const ShowWhatsAppService = async (id: string | number): Promise => { + const whatsapp = await Whatsapp.findByPk(id, { + include: [ + { + model: Queue, + as: "queues", + attributes: ["id", "name", "color", "greetingMessage"] + } + ], + order: [["queues", "name", "ASC"]] + }); + + if (!whatsapp) { + throw new AppError("ERR_NO_WAPP_FOUND", 404); + } + + return whatsapp; +}; + +export default ShowWhatsAppService; diff --git a/backend/src/services/WhatsappService/UpdateWhatsAppService.ts b/backend/src/services/WhatsappService/UpdateWhatsAppService.ts new file mode 100644 index 0000000..5c6fde7 --- /dev/null +++ b/backend/src/services/WhatsappService/UpdateWhatsAppService.ts @@ -0,0 +1,86 @@ +import * as Yup from "yup"; +import { Op } from "sequelize"; + +import AppError from "../../errors/AppError"; +import Whatsapp from "../../models/Whatsapp"; +import ShowWhatsAppService from "./ShowWhatsAppService"; +import AssociateWhatsappQueue from "./AssociateWhatsappQueue"; + +interface WhatsappData { + name?: string; + status?: string; + session?: string; + isDefault?: boolean; + greetingMessage?: string; + farewellMessage?: string; + queueIds?: number[]; +} + +interface Request { + whatsappData: WhatsappData; + whatsappId: string; +} + +interface Response { + whatsapp: Whatsapp; + oldDefaultWhatsapp: Whatsapp | null; +} + +const UpdateWhatsAppService = async ({ + whatsappData, + whatsappId +}: Request): Promise => { + const schema = Yup.object().shape({ + name: Yup.string().min(2), + status: Yup.string(), + isDefault: Yup.boolean() + }); + + const { + name, + status, + isDefault, + session, + greetingMessage, + farewellMessage, + queueIds = [] + } = whatsappData; + + try { + await schema.validate({ name, status, isDefault }); + } catch (err) { + throw new AppError(err.message); + } + + if (queueIds.length > 1 && !greetingMessage) { + throw new AppError("ERR_WAPP_GREETING_REQUIRED"); + } + + let oldDefaultWhatsapp: Whatsapp | null = null; + + if (isDefault) { + oldDefaultWhatsapp = await Whatsapp.findOne({ + where: { isDefault: true, id: { [Op.not]: whatsappId } } + }); + if (oldDefaultWhatsapp) { + await oldDefaultWhatsapp.update({ isDefault: false }); + } + } + + const whatsapp = await ShowWhatsAppService(whatsappId); + + await whatsapp.update({ + name, + status, + session, + greetingMessage, + farewellMessage, + isDefault + }); + + await AssociateWhatsappQueue(whatsapp, queueIds); + + return { whatsapp, oldDefaultWhatsapp }; +}; + +export default UpdateWhatsAppService; diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 0000000..3096ee9 --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,9 @@ +import pino from "pino"; + +const logger = pino({ + prettyPrint: { + ignore: "pid,hostname" + } +}); + +export { logger }; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..95db251 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "outDir": "./dist", + "strict": true, + "strictPropertyInitialization": false, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..f890a22 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +REACT_APP_BACKEND_URL = http://localhost:8080/ \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..6b5fbe4 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,28 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..acc009a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,55 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "dependencies": { + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.56", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.0.4", + "@testing-library/user-event": "^12.1.7", + "axios": "^0.21.1", + "date-fns": "^2.16.1", + "emoji-mart": "^3.0.1", + "formik": "^2.2.0", + "i18next": "^19.8.2", + "i18next-browser-languagedetector": "^6.0.1", + "markdown-to-jsx": "^7.1.0", + "mic-recorder-to-mp3": "^2.2.2", + "qrcode.react": "^1.0.0", + "react": "^16.13.1", + "react-color": "^2.19.3", + "react-dom": "^16.13.1", + "react-modal-image": "^2.5.0", + "react-router-dom": "^5.2.0", + "react-scripts": "3.4.3", + "react-toastify": "^6.0.9", + "recharts": "^2.0.2", + "socket.io-client": "^3.0.5", + "use-sound": "^2.0.1", + "yup": "^0.32.8" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": {} +} diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png new file mode 100644 index 0000000..ceef74f Binary files /dev/null and b/frontend/public/android-chrome-192x192.png differ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..758b8c8 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 0000000..1de1e2e Binary files /dev/null and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..1c174e6 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..d58ccc4 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..59581ba --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,23 @@ + + + + WhaTicket + + + + + + + + + + +
+ + diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..72e9d5a --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,20 @@ +{ + "short_name": "WhaTicket", + "name": "WhaTicket", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/public/mstile-150x150.png b/frontend/public/mstile-150x150.png new file mode 100644 index 0000000..9999341 Binary files /dev/null and b/frontend/public/mstile-150x150.png differ diff --git a/frontend/server.js b/frontend/server.js new file mode 100644 index 0000000..a05ba32 --- /dev/null +++ b/frontend/server.js @@ -0,0 +1,9 @@ +//simple express server to run frontend production build; +const express = require("express"); +const path = require("path"); +const app = express(); +app.use(express.static(path.join(__dirname, "build"))); +app.get("/*", function (req, res) { + res.sendFile(path.join(__dirname, "build", "index.html")); +}); +app.listen(3333); diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..ff22dac --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,47 @@ +import React, { useState, useEffect } from "react"; +import Routes from "./routes"; +import "react-toastify/dist/ReactToastify.css"; + +import { createTheme, ThemeProvider } from "@material-ui/core/styles"; +import { ptBR } from "@material-ui/core/locale"; + +const App = () => { + const [locale, setLocale] = useState(); + + const theme = createTheme( + { + 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: "#2576d2" }, + }, + }, + locale + ); + + useEffect(() => { + const i18nlocale = localStorage.getItem("i18nextLng"); + const browserLocale = + i18nlocale.substring(0, 2) + i18nlocale.substring(3, 5); + + if (browserLocale === "ptBR") { + setLocale(ptBR); + } + }, []); + + return ( + + + + ); +}; + +export default App; diff --git a/frontend/src/assets/sound.mp3 b/frontend/src/assets/sound.mp3 new file mode 100644 index 0000000..bd770d8 Binary files /dev/null and b/frontend/src/assets/sound.mp3 differ diff --git a/frontend/src/assets/sound.ogg b/frontend/src/assets/sound.ogg new file mode 100644 index 0000000..5a7c0ab Binary files /dev/null and b/frontend/src/assets/sound.ogg differ diff --git a/frontend/src/assets/wa-background.png b/frontend/src/assets/wa-background.png new file mode 100644 index 0000000..301d951 Binary files /dev/null and b/frontend/src/assets/wa-background.png differ diff --git a/frontend/src/components/BackdropLoading/index.js b/frontend/src/components/BackdropLoading/index.js new file mode 100644 index 0000000..183b02b --- /dev/null +++ b/frontend/src/components/BackdropLoading/index.js @@ -0,0 +1,23 @@ +import React from "react"; + +import Backdrop from "@material-ui/core/Backdrop"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import { makeStyles } from "@material-ui/core/styles"; + +const useStyles = makeStyles(theme => ({ + backdrop: { + zIndex: theme.zIndex.drawer + 1, + color: "#fff", + }, +})); + +const BackdropLoading = () => { + const classes = useStyles(); + return ( + + + + ); +}; + +export default BackdropLoading; diff --git a/frontend/src/components/ButtonWithSpinner/index.js b/frontend/src/components/ButtonWithSpinner/index.js new file mode 100644 index 0000000..542c39c --- /dev/null +++ b/frontend/src/components/ButtonWithSpinner/index.js @@ -0,0 +1,35 @@ +import React from "react"; + +import { makeStyles } from "@material-ui/core/styles"; +import { green } from "@material-ui/core/colors"; +import { CircularProgress, Button } from "@material-ui/core"; + +const useStyles = makeStyles(theme => ({ + button: { + position: "relative", + }, + + buttonProgress: { + color: green[500], + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, +})); + +const ButtonWithSpinner = ({ loading, children, ...rest }) => { + const classes = useStyles(); + + return ( + + ); +}; + +export default ButtonWithSpinner; diff --git a/frontend/src/components/Can/index.js b/frontend/src/components/Can/index.js new file mode 100644 index 0000000..3d80215 --- /dev/null +++ b/frontend/src/components/Can/index.js @@ -0,0 +1,39 @@ +import rules from "../../rules"; + +const check = (role, action, data) => { + const permissions = rules[role]; + if (!permissions) { + // role is not present in the rules + return false; + } + + const staticPermissions = permissions.static; + + if (staticPermissions && staticPermissions.includes(action)) { + // static rule not provided for action + return true; + } + + const dynamicPermissions = permissions.dynamic; + + if (dynamicPermissions) { + const permissionCondition = dynamicPermissions[action]; + if (!permissionCondition) { + // dynamic rule not provided for action + return false; + } + + return permissionCondition(data); + } + return false; +}; + +const Can = ({ role, perform, data, yes, no }) => + check(role, perform, data) ? yes() : no(); + +Can.defaultProps = { + yes: () => null, + no: () => null, +}; + +export { Can }; diff --git a/frontend/src/components/ColorPicker/index.js b/frontend/src/components/ColorPicker/index.js new file mode 100644 index 0000000..7cf1396 --- /dev/null +++ b/frontend/src/components/ColorPicker/index.js @@ -0,0 +1,87 @@ +import { Dialog } from "@material-ui/core"; +import React, { useState } from "react"; + +import { GithubPicker } from "react-color"; + +const ColorPicker = ({ onChange, currentColor, handleClose, open }) => { + const [selectedColor, setSelectedColor] = useState(currentColor); + const colors = [ + "#B80000", + "#DB3E00", + "#FCCB00", + "#008B02", + "#006B76", + "#1273DE", + "#004DCF", + "#5300EB", + "#EB9694", + "#FAD0C3", + "#FEF3BD", + "#C1E1C5", + "#BEDADC", + "#C4DEF6", + "#BED3F3", + "#D4C4FB", + "#4D4D4D", + "#999999", + "#FFFFFF", + "#F44E3B", + "#FE9200", + "#FCDC00", + "#DBDF00", + "#A4DD00", + "#68CCCA", + "#73D8FF", + "#AEA1FF", + "#FDA1FF", + "#333333", + "#808080", + "#cccccc", + "#D33115", + "#E27300", + "#FCC400", + "#B0BC00", + "#68BC00", + "#16A5A5", + "#009CE0", + "#7B64FF", + "#FA28FF", + "#666666", + "#B3B3B3", + "#9F0500", + "#C45100", + "#FB9E00", + "#808900", + "#194D33", + "#0C797D", + "#0062B1", + "#653294", + "#AB149E", + ]; + + const handleChange = (color) => { + setSelectedColor(color.hex); + handleClose(); + }; + + return ( + + onChange(color.hex)} + /> + + ); +}; + +export default ColorPicker; diff --git a/frontend/src/components/ConfirmationModal/index.js b/frontend/src/components/ConfirmationModal/index.js new file mode 100644 index 0000000..ce340f2 --- /dev/null +++ b/frontend/src/components/ConfirmationModal/index.js @@ -0,0 +1,45 @@ +import React from "react"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import Typography from "@material-ui/core/Typography"; + +import { i18n } from "../../translate/i18n"; + +const ConfirmationModal = ({ title, children, open, onClose, onConfirm }) => { + return ( + onClose(false)} + aria-labelledby="confirm-dialog" + > + {title} + + {children} + + + + + + + ); +}; + +export default ConfirmationModal; diff --git a/frontend/src/components/ContactDrawer/index.js b/frontend/src/components/ContactDrawer/index.js new file mode 100644 index 0000000..c8d16f4 --- /dev/null +++ b/frontend/src/components/ContactDrawer/index.js @@ -0,0 +1,165 @@ +import React, { useState } from "react"; + +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import IconButton from "@material-ui/core/IconButton"; +import CloseIcon from "@material-ui/icons/Close"; +import Drawer from "@material-ui/core/Drawer"; +import Link from "@material-ui/core/Link"; +import InputLabel from "@material-ui/core/InputLabel"; +import Avatar from "@material-ui/core/Avatar"; +import Button from "@material-ui/core/Button"; +import Paper from "@material-ui/core/Paper"; + +import { i18n } from "../../translate/i18n"; + +import ContactModal from "../ContactModal"; +import ContactDrawerSkeleton from "../ContactDrawerSkeleton"; +import MarkdownWrapper from "../MarkdownWrapper"; + +const drawerWidth = 320; + +const useStyles = makeStyles(theme => ({ + drawer: { + width: drawerWidth, + flexShrink: 0, + }, + drawerPaper: { + width: drawerWidth, + display: "flex", + borderTop: "1px solid rgba(0, 0, 0, 0.12)", + borderRight: "1px solid rgba(0, 0, 0, 0.12)", + borderBottom: "1px solid rgba(0, 0, 0, 0.12)", + borderTopRightRadius: 4, + borderBottomRightRadius: 4, + }, + header: { + display: "flex", + borderBottom: "1px solid rgba(0, 0, 0, 0.12)", + backgroundColor: "#eee", + alignItems: "center", + padding: theme.spacing(0, 1), + minHeight: "73px", + justifyContent: "flex-start", + }, + content: { + display: "flex", + backgroundColor: "#eee", + flexDirection: "column", + padding: "8px 0px 8px 8px", + height: "100%", + overflowY: "scroll", + ...theme.scrollbarStyles, + }, + + contactAvatar: { + margin: 15, + width: 160, + height: 160, + }, + + contactHeader: { + display: "flex", + padding: 8, + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + "& > *": { + margin: 4, + }, + }, + + contactDetails: { + marginTop: 8, + padding: 8, + display: "flex", + flexDirection: "column", + }, + contactExtraInfo: { + marginTop: 4, + padding: 6, + }, +})); + +const ContactDrawer = ({ open, handleDrawerClose, contact, loading }) => { + const classes = useStyles(); + + const [modalOpen, setModalOpen] = useState(false); + + return ( + +
+ + + + + {i18n.t("contactDrawer.header")} + +
+ {loading ? ( + + ) : ( +
+ + + + {contact.name} + + {contact.number} + + + + + setModalOpen(false)} + contactId={contact.id} + > + + {i18n.t("contactDrawer.extraInfo")} + + {contact?.extraInfo?.map(info => ( + + {info.name} + + {info.value} + + + ))} + +
+ )} +
+ ); +}; + +export default ContactDrawer; diff --git a/frontend/src/components/ContactDrawerSkeleton/index.js b/frontend/src/components/ContactDrawerSkeleton/index.js new file mode 100644 index 0000000..481afd5 --- /dev/null +++ b/frontend/src/components/ContactDrawerSkeleton/index.js @@ -0,0 +1,43 @@ +import React from "react"; +import Skeleton from "@material-ui/lab/Skeleton"; +import Typography from "@material-ui/core/Typography"; +import Paper from "@material-ui/core/Paper"; +import { i18n } from "../../translate/i18n"; + +const ContactDrawerSkeleton = ({ classes }) => { + return ( +
+ + + + + + + + + {i18n.t("contactDrawer.extraInfo")} + + + + + + + + + + + + + + +
+ ); +}; + +export default ContactDrawerSkeleton; diff --git a/frontend/src/components/ContactModal/index.js b/frontend/src/components/ContactModal/index.js new file mode 100644 index 0000000..e4fad1f --- /dev/null +++ b/frontend/src/components/ContactModal/index.js @@ -0,0 +1,277 @@ +import React, { useState, useEffect, useRef } from "react"; + +import * as Yup from "yup"; +import { Formik, FieldArray, Form, Field } from "formik"; +import { toast } from "react-toastify"; + +import { makeStyles } from "@material-ui/core/styles"; +import { green } from "@material-ui/core/colors"; +import Button from "@material-ui/core/Button"; +import TextField from "@material-ui/core/TextField"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import Typography from "@material-ui/core/Typography"; +import IconButton from "@material-ui/core/IconButton"; +import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline"; +import CircularProgress from "@material-ui/core/CircularProgress"; + +import { i18n } from "../../translate/i18n"; + +import api from "../../services/api"; +import toastError from "../../errors/toastError"; + +const useStyles = makeStyles(theme => ({ + root: { + display: "flex", + flexWrap: "wrap", + }, + textField: { + marginRight: theme.spacing(1), + flex: 1, + }, + + extraAttr: { + display: "flex", + justifyContent: "center", + alignItems: "center", + }, + + btnWrapper: { + position: "relative", + }, + + buttonProgress: { + color: green[500], + position: "absolute", + top: "50%", + left: "50%", + marginTop: -12, + marginLeft: -12, + }, +})); + +const ContactSchema = Yup.object().shape({ + name: Yup.string() + .min(2, "Too Short!") + .max(50, "Too Long!") + .required("Required"), + number: Yup.string().min(8, "Too Short!").max(50, "Too Long!"), + email: Yup.string().email("Invalid email"), +}); + +const ContactModal = ({ open, onClose, contactId, initialValues, onSave }) => { + const classes = useStyles(); + const isMounted = useRef(true); + + const initialState = { + name: "", + number: "", + email: "", + }; + + const [contact, setContact] = useState(initialState); + + useEffect(() => { + return () => { + isMounted.current = false; + }; + }, []); + + useEffect(() => { + const fetchContact = async () => { + if (initialValues) { + setContact(prevState => { + return { ...prevState, ...initialValues }; + }); + } + + if (!contactId) return; + + try { + const { data } = await api.get(`/contacts/${contactId}`); + if (isMounted.current) { + setContact(data); + } + } catch (err) { + toastError(err); + } + }; + + fetchContact(); + }, [contactId, open, initialValues]); + + const handleClose = () => { + onClose(); + setContact(initialState); + }; + + const handleSaveContact = async values => { + try { + if (contactId) { + await api.put(`/contacts/${contactId}`, values); + handleClose(); + } else { + const { data } = await api.post("/contacts", values); + if (onSave) { + onSave(data); + } + handleClose(); + } + toast.success(i18n.t("contactModal.success")); + } catch (err) { + toastError(err); + } + }; + + return ( +
+ + + {contactId + ? `${i18n.t("contactModal.title.edit")}` + : `${i18n.t("contactModal.title.add")}`} + + { + setTimeout(() => { + handleSaveContact(values); + actions.setSubmitting(false); + }, 400); + }} + > + {({ values, errors, touched, isSubmitting }) => ( +
+ + + {i18n.t("contactModal.form.mainInfo")} + + + +
+ +
+ + {i18n.t("contactModal.form.extraInfo")} + + + + {({ push, remove }) => ( + <> + {values.extraInfo && + values.extraInfo.length > 0 && + values.extraInfo.map((info, index) => ( +
+ + + remove(index)} + > + + +
+ ))} +
+ +
+ + )} +
+
+ + + + +
+ )} +
+
+
+ ); +}; + +export default ContactModal; diff --git a/frontend/src/components/MainContainer/index.js b/frontend/src/components/MainContainer/index.js new file mode 100644 index 0000000..0045703 --- /dev/null +++ b/frontend/src/components/MainContainer/index.js @@ -0,0 +1,33 @@ +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)`, + padding: 0, + height: "100%", + }, + + contentWrapper: { + height: "100%", + overflowY: "hidden", + display: "flex", + flexDirection: "column", + }, +})); + +const MainContainer = ({ children }) => { + const classes = useStyles(); + + return ( + +
{children}
+
+ ); +}; + +export default MainContainer; diff --git a/frontend/src/components/MainHeader/index.js b/frontend/src/components/MainHeader/index.js new file mode 100644 index 0000000..46fa8ab --- /dev/null +++ b/frontend/src/components/MainHeader/index.js @@ -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
{children}
; +}; + +export default MainHeader; diff --git a/frontend/src/components/MainHeaderButtonsWrapper/index.js b/frontend/src/components/MainHeaderButtonsWrapper/index.js new file mode 100644 index 0000000..ed5887c --- /dev/null +++ b/frontend/src/components/MainHeaderButtonsWrapper/index.js @@ -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
{children}
; +}; + +export default MainHeaderButtonsWrapper; diff --git a/frontend/src/components/MarkdownWrapper/index.js b/frontend/src/components/MarkdownWrapper/index.js new file mode 100644 index 0000000..64764b2 --- /dev/null +++ b/frontend/src/components/MarkdownWrapper/index.js @@ -0,0 +1,186 @@ +import React from "react"; +import Markdown from "markdown-to-jsx"; + +const elements = [ + "a", + "abbr", + "address", + "area", + "article", + "aside", + "audio", + "b", + "base", + "bdi", + "bdo", + "big", + "blockquote", + "body", + "br", + "button", + "canvas", + "caption", + "cite", + "code", + "col", + "colgroup", + "data", + "datalist", + "dd", + "del", + "details", + "dfn", + "dialog", + "div", + "dl", + "dt", + "em", + "embed", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hgroup", + "hr", + "html", + "i", + "iframe", + "img", + "input", + "ins", + "kbd", + "keygen", + "label", + "legend", + "li", + "link", + "main", + "map", + "mark", + "marquee", + "menu", + "menuitem", + "meta", + "meter", + "nav", + "noscript", + "object", + "ol", + "optgroup", + "option", + "output", + "p", + "param", + "picture", + "pre", + "progress", + "q", + "rp", + "rt", + "ruby", + "s", + "samp", + "script", + "section", + "select", + "small", + "source", + "span", + "strong", + "style", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "textarea", + "tfoot", + "th", + "thead", + "time", + "title", + "tr", + "track", + "u", + "ul", + "var", + "video", + "wbr", + + // SVG + "circle", + "clipPath", + "defs", + "ellipse", + "foreignObject", + "g", + "image", + "line", + "linearGradient", + "marker", + "mask", + "path", + "pattern", + "polygon", + "polyline", + "radialGradient", + "rect", + "stop", + "svg", + "text", + "tspan", +]; + +const allowedElements = ["a", "b", "strong", "em", "u", "code", "del"]; + +const CustomLink = ({ children, ...props }) => ( + + {children} + +); + +const MarkdownWrapper = ({ children }) => { + const boldRegex = /\*(.*?)\*/g; + const tildaRegex = /~(.*?)~/g; + + if (children && boldRegex.test(children)) { + children = children.replace(boldRegex, "**$1**"); + } + if (children && tildaRegex.test(children)) { + children = children.replace(tildaRegex, "~~$1~~"); + } + + const options = React.useMemo(() => { + const markdownOptions = { + disableParsingRawHTML: true, + forceInline: true, + overrides: { + a: { component: CustomLink }, + }, + }; + + elements.forEach(element => { + if (!allowedElements.includes(element)) { + markdownOptions.overrides[element] = el => el.children || null; + } + }); + + return markdownOptions; + }, []); + + if (!children) return null; + + return {children}; +}; + +export default MarkdownWrapper; diff --git a/frontend/src/components/MessageInput/RecordingTimer.js b/frontend/src/components/MessageInput/RecordingTimer.js new file mode 100644 index 0000000..108cf50 --- /dev/null +++ b/frontend/src/components/MessageInput/RecordingTimer.js @@ -0,0 +1,48 @@ +import React, { useState, useEffect } from "react"; +import { makeStyles } from "@material-ui/core/styles"; + +const useStyles = makeStyles(theme => ({ + timerBox: { + display: "flex", + marginLeft: 10, + marginRight: 10, + alignItems: "center", + }, +})); + +const RecordingTimer = () => { + const classes = useStyles(); + const initialState = { + minutes: 0, + seconds: 0, + }; + const [timer, setTimer] = useState(initialState); + + useEffect(() => { + const interval = setInterval( + () => + setTimer(prevState => { + if (prevState.seconds === 59) { + return { ...prevState, minutes: prevState.minutes + 1, seconds: 0 }; + } + return { ...prevState, seconds: prevState.seconds + 1 }; + }), + 1000 + ); + return () => { + clearInterval(interval); + }; + }, []); + + const addZero = n => { + return n < 10 ? "0" + n : n; + }; + + return ( +
+ {`${addZero(timer.minutes)}:${addZero(timer.seconds)}`} +
+ ); +}; + +export default RecordingTimer; diff --git a/frontend/src/components/MessageInput/index.js b/frontend/src/components/MessageInput/index.js new file mode 100644 index 0000000..3a33829 --- /dev/null +++ b/frontend/src/components/MessageInput/index.js @@ -0,0 +1,674 @@ +import React, { useState, useEffect, useContext, useRef } from "react"; +import "emoji-mart/css/emoji-mart.css"; +import { useParams } from "react-router-dom"; +import { Picker } from "emoji-mart"; +import MicRecorder from "mic-recorder-to-mp3"; +import clsx from "clsx"; + +import { makeStyles } from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import InputBase from "@material-ui/core/InputBase"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import { green } from "@material-ui/core/colors"; +import AttachFileIcon from "@material-ui/icons/AttachFile"; +import IconButton from "@material-ui/core/IconButton"; +import MoreVert from "@material-ui/icons/MoreVert"; +import MoodIcon from "@material-ui/icons/Mood"; +import SendIcon from "@material-ui/icons/Send"; +import CancelIcon from "@material-ui/icons/Cancel"; +import ClearIcon from "@material-ui/icons/Clear"; +import MicIcon from "@material-ui/icons/Mic"; +import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline"; +import HighlightOffIcon from "@material-ui/icons/HighlightOff"; +import { + FormControlLabel, + Hidden, + Menu, + MenuItem, + Switch, +} from "@material-ui/core"; +import ClickAwayListener from "@material-ui/core/ClickAwayListener"; + +import { i18n } from "../../translate/i18n"; +import api from "../../services/api"; +import RecordingTimer from "./RecordingTimer"; +import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext"; +import { AuthContext } from "../../context/Auth/AuthContext"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import toastError from "../../errors/toastError"; + +const Mp3Recorder = new MicRecorder({ bitRate: 128 }); + +const useStyles = makeStyles((theme) => ({ + mainWrapper: { + background: "#eee", + display: "flex", + flexDirection: "column", + alignItems: "center", + borderTop: "1px solid rgba(0, 0, 0, 0.12)", + [theme.breakpoints.down("sm")]: { + position: "fixed", + bottom: 0, + width: "100%", + }, + }, + + newMessageBox: { + background: "#eee", + width: "100%", + display: "flex", + padding: "7px", + alignItems: "center", + }, + + messageInputWrapper: { + padding: 6, + marginRight: 7, + background: "#fff", + display: "flex", + borderRadius: 20, + flex: 1, + position: "relative", + }, + + messageInput: { + paddingLeft: 10, + flex: 1, + border: "none", + }, + + sendMessageIcons: { + color: "grey", + }, + + uploadInput: { + display: "none", + }, + + viewMediaInputWrapper: { + display: "flex", + padding: "10px 13px", + position: "relative", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: "#eee", + borderTop: "1px solid rgba(0, 0, 0, 0.12)", + }, + + emojiBox: { + position: "absolute", + bottom: 63, + width: 40, + borderTop: "1px solid #e8e8e8", + }, + + circleLoading: { + color: green[500], + opacity: "70%", + position: "absolute", + top: "20%", + left: "50%", + marginLeft: -12, + }, + + audioLoading: { + color: green[500], + opacity: "70%", + }, + + recorderWrapper: { + display: "flex", + alignItems: "center", + alignContent: "middle", + }, + + cancelAudioIcon: { + color: "red", + }, + + sendAudioIcon: { + color: "green", + }, + + replyginMsgWrapper: { + display: "flex", + width: "100%", + alignItems: "center", + justifyContent: "center", + paddingTop: 8, + paddingLeft: 73, + paddingRight: 7, + }, + + replyginMsgContainer: { + flex: 1, + marginRight: 5, + overflowY: "hidden", + backgroundColor: "rgba(0, 0, 0, 0.05)", + borderRadius: "7.5px", + display: "flex", + position: "relative", + }, + + replyginMsgBody: { + padding: 10, + height: "auto", + display: "block", + whiteSpace: "pre-wrap", + overflow: "hidden", + }, + + replyginContactMsgSideColor: { + flex: "none", + width: "4px", + backgroundColor: "#35cd96", + }, + + replyginSelfMsgSideColor: { + flex: "none", + width: "4px", + backgroundColor: "#6bcbef", + }, + + messageContactName: { + display: "flex", + color: "#6bcbef", + fontWeight: 500, + }, + messageQuickAnswersWrapper: { + margin: 0, + position: "absolute", + bottom: "50px", + background: "#ffffff", + padding: "2px", + border: "1px solid #CCC", + left: 0, + width: "100%", + "& li": { + listStyle: "none", + "& a": { + display: "block", + padding: "8px", + textOverflow: "ellipsis", + overflow: "hidden", + maxHeight: "32px", + "&:hover": { + background: "#F1F1F1", + cursor: "pointer", + }, + }, + }, + }, +})); + +const MessageInput = ({ ticketStatus }) => { + const classes = useStyles(); + const { ticketId } = useParams(); + + const [medias, setMedias] = useState([]); + const [inputMessage, setInputMessage] = useState(""); + const [showEmoji, setShowEmoji] = useState(false); + const [loading, setLoading] = useState(false); + const [recording, setRecording] = useState(false); + const [quickAnswers, setQuickAnswer] = useState([]); + const [typeBar, setTypeBar] = useState(false); + const inputRef = useRef(); + const [anchorEl, setAnchorEl] = useState(null); + const { setReplyingMessage, replyingMessage } = + useContext(ReplyMessageContext); + const { user } = useContext(AuthContext); + + const [signMessage, setSignMessage] = useLocalStorage("signOption", true); + + useEffect(() => { + inputRef.current.focus(); + }, [replyingMessage]); + + useEffect(() => { + inputRef.current.focus(); + return () => { + setInputMessage(""); + setShowEmoji(false); + setMedias([]); + setReplyingMessage(null); + }; + }, [ticketId, setReplyingMessage]); + + const handleChangeInput = (e) => { + setInputMessage(e.target.value); + handleLoadQuickAnswer(e.target.value); + }; + + const handleQuickAnswersClick = (value) => { + setInputMessage(value); + setTypeBar(false); + }; + + const handleAddEmoji = (e) => { + let emoji = e.native; + setInputMessage((prevState) => prevState + emoji); + }; + + const handleChangeMedias = (e) => { + if (!e.target.files) { + return; + } + + const selectedMedias = Array.from(e.target.files); + setMedias(selectedMedias); + }; + + const handleInputPaste = (e) => { + if (e.clipboardData.files[0]) { + setMedias([e.clipboardData.files[0]]); + } + }; + + const handleUploadMedia = async (e) => { + setLoading(true); + e.preventDefault(); + + const formData = new FormData(); + formData.append("fromMe", true); + medias.forEach((media) => { + formData.append("medias", media); + formData.append("body", media.name); + }); + + try { + await api.post(`/messages/${ticketId}`, formData); + } catch (err) { + toastError(err); + } + + setLoading(false); + setMedias([]); + }; + + const handleSendMessage = async () => { + if (inputMessage.trim() === "") return; + setLoading(true); + + const message = { + read: 1, + fromMe: true, + mediaUrl: "", + body: signMessage + ? `*${user?.name}:*\n${inputMessage.trim()}` + : inputMessage.trim(), + quotedMsg: replyingMessage, + }; + try { + await api.post(`/messages/${ticketId}`, message); + } catch (err) { + toastError(err); + } + + setInputMessage(""); + setShowEmoji(false); + setLoading(false); + setReplyingMessage(null); + }; + + const handleStartRecording = async () => { + setLoading(true); + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + await Mp3Recorder.start(); + setRecording(true); + setLoading(false); + } catch (err) { + toastError(err); + setLoading(false); + } + }; + + const handleLoadQuickAnswer = async (value) => { + if (value && value.indexOf("/") === 0) { + try { + const { data } = await api.get("/quickAnswers/", { + params: { searchParam: inputMessage.substring(1) }, + }); + setQuickAnswer(data.quickAnswers); + if (data.quickAnswers.length > 0) { + setTypeBar(true); + } else { + setTypeBar(false); + } + } catch (err) { + setTypeBar(false); + } + } else { + setTypeBar(false); + } + }; + + const handleUploadAudio = async () => { + setLoading(true); + try { + const [, blob] = await Mp3Recorder.stop().getMp3(); + if (blob.size < 10000) { + setLoading(false); + setRecording(false); + return; + } + + const formData = new FormData(); + const filename = `${new Date().getTime()}.mp3`; + formData.append("medias", blob, filename); + formData.append("body", filename); + formData.append("fromMe", true); + + await api.post(`/messages/${ticketId}`, formData); + } catch (err) { + toastError(err); + } + + setRecording(false); + setLoading(false); + }; + + const handleCancelAudio = async () => { + try { + await Mp3Recorder.stop().getMp3(); + setRecording(false); + } catch (err) { + toastError(err); + } + }; + + const handleOpenMenuClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuItemClick = (event) => { + setAnchorEl(null); + }; + + const renderReplyingMessage = (message) => { + return ( +
+
+ +
+ {!message.fromMe && ( + + {message.contact?.name} + + )} + {message.body} +
+
+ setReplyingMessage(null)} + > + + +
+ ); + }; + + if (medias.length > 0) + return ( + + setMedias([])} + > + + + + {loading ? ( +
+ +
+ ) : ( + + {medias[0]?.name} + {/* */} + + )} + + + +
+ ); + else { + return ( + + {replyingMessage && renderReplyingMessage(replyingMessage)} +
+ + setShowEmoji((prevState) => !prevState)} + > + + + {showEmoji ? ( +
+ setShowEmoji(false)}> + + +
+ ) : null} + + + + { + setSignMessage(e.target.checked); + }} + name="showAllTickets" + color="primary" + /> + } + /> +
+ + + + + + + setShowEmoji((prevState) => !prevState)} + > + + + + + + + + + { + setSignMessage(e.target.checked); + }} + name="showAllTickets" + color="primary" + /> + } + /> + + + +
+ { + input && input.focus(); + input && (inputRef.current = input); + }} + className={classes.messageInput} + placeholder={ + ticketStatus === "open" + ? i18n.t("messagesInput.placeholderOpen") + : i18n.t("messagesInput.placeholderClosed") + } + multiline + rowsMax={5} + value={inputMessage} + onChange={handleChangeInput} + disabled={recording || loading || ticketStatus !== "open"} + onPaste={(e) => { + ticketStatus === "open" && handleInputPaste(e); + }} + onKeyPress={(e) => { + if (loading || e.shiftKey) return; + else if (e.key === "Enter") { + handleSendMessage(); + } + }} + /> + {typeBar ? ( + + ) : ( +
+ )} +
+ {inputMessage ? ( + + + + ) : recording ? ( +
+ + + + {loading ? ( +
+ +
+ ) : ( + + )} + + + + +
+ ) : ( + + + + )} +
+
+ ); + } +}; + +export default MessageInput; diff --git a/frontend/src/components/MessageOptionsMenu/index.js b/frontend/src/components/MessageOptionsMenu/index.js new file mode 100644 index 0000000..ea0dd0b --- /dev/null +++ b/frontend/src/components/MessageOptionsMenu/index.js @@ -0,0 +1,71 @@ +import React, { useState, useContext } from "react"; + +import MenuItem from "@material-ui/core/MenuItem"; + +import { i18n } from "../../translate/i18n"; +import api from "../../services/api"; +import ConfirmationModal from "../ConfirmationModal"; +import { Menu } from "@material-ui/core"; +import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext"; +import toastError from "../../errors/toastError"; + +const MessageOptionsMenu = ({ message, menuOpen, handleClose, anchorEl }) => { + const { setReplyingMessage } = useContext(ReplyMessageContext); + const [confirmationOpen, setConfirmationOpen] = useState(false); + + const handleDeleteMessage = async () => { + try { + await api.delete(`/messages/${message.id}`); + } catch (err) { + toastError(err); + } + }; + + const hanldeReplyMessage = () => { + setReplyingMessage(message); + handleClose(); + }; + + const handleOpenConfirmationModal = (e) => { + setConfirmationOpen(true); + handleClose(); + }; + + return ( + <> + + {i18n.t("messageOptionsMenu.confirmationModal.message")} + + + {message.fromMe && ( + + {i18n.t("messageOptionsMenu.delete")} + + )} + + {i18n.t("messageOptionsMenu.reply")} + + + + ); +}; + +export default MessageOptionsMenu; diff --git a/frontend/src/components/MessagesList/index.js b/frontend/src/components/MessagesList/index.js new file mode 100644 index 0000000..ef91b81 --- /dev/null +++ b/frontend/src/components/MessagesList/index.js @@ -0,0 +1,654 @@ +import React, { useState, useEffect, useReducer, useRef } from "react"; + +import { isSameDay, parseISO, format } from "date-fns"; +import openSocket from "socket.io-client"; +import clsx from "clsx"; + +import { green } from "@material-ui/core/colors"; +import { + Button, + CircularProgress, + Divider, + IconButton, + makeStyles, +} from "@material-ui/core"; +import { + AccessTime, + Block, + Done, + DoneAll, + ExpandMore, + GetApp, +} from "@material-ui/icons"; + +import MarkdownWrapper from "../MarkdownWrapper"; +import ModalImageCors from "../ModalImageCors"; +import MessageOptionsMenu from "../MessageOptionsMenu"; +import whatsBackground from "../../assets/wa-background.png"; + +import api from "../../services/api"; +import toastError from "../../errors/toastError"; + +const useStyles = makeStyles((theme) => ({ + messagesListWrapper: { + overflow: "hidden", + position: "relative", + display: "flex", + flexDirection: "column", + flexGrow: 1, + }, + + messagesList: { + backgroundImage: `url(${whatsBackground})`, + display: "flex", + flexDirection: "column", + flexGrow: 1, + padding: "20px 20px 20px 20px", + overflowY: "scroll", + [theme.breakpoints.down("sm")]: { + paddingBottom: "90px", + }, + ...theme.scrollbarStyles, + }, + + circleLoading: { + color: green[500], + position: "absolute", + opacity: "70%", + top: 0, + left: "50%", + marginTop: 12, + }, + + messageLeft: { + marginRight: 20, + marginTop: 2, + minWidth: 100, + maxWidth: 600, + height: "auto", + display: "block", + position: "relative", + "&:hover #messageActionsButton": { + display: "flex", + position: "absolute", + top: 0, + right: 0, + }, + + whiteSpace: "pre-wrap", + backgroundColor: "#ffffff", + color: "#303030", + alignSelf: "flex-start", + borderTopLeftRadius: 0, + borderTopRightRadius: 8, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + paddingLeft: 5, + paddingRight: 5, + paddingTop: 5, + paddingBottom: 0, + boxShadow: "0 1px 1px #b3b3b3", + }, + + quotedContainerLeft: { + margin: "-3px -80px 6px -6px", + overflow: "hidden", + backgroundColor: "#f0f0f0", + borderRadius: "7.5px", + display: "flex", + position: "relative", + }, + + quotedMsg: { + padding: 10, + maxWidth: 300, + height: "auto", + display: "block", + whiteSpace: "pre-wrap", + overflow: "hidden", + }, + + quotedSideColorLeft: { + flex: "none", + width: "4px", + backgroundColor: "#6bcbef", + }, + + messageRight: { + marginLeft: 20, + marginTop: 2, + minWidth: 100, + maxWidth: 600, + height: "auto", + display: "block", + position: "relative", + "&:hover #messageActionsButton": { + display: "flex", + position: "absolute", + top: 0, + right: 0, + }, + + whiteSpace: "pre-wrap", + backgroundColor: "#dcf8c6", + color: "#303030", + alignSelf: "flex-end", + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 0, + paddingLeft: 5, + paddingRight: 5, + paddingTop: 5, + paddingBottom: 0, + boxShadow: "0 1px 1px #b3b3b3", + }, + + quotedContainerRight: { + margin: "-3px -80px 6px -6px", + overflowY: "hidden", + backgroundColor: "#cfe9ba", + borderRadius: "7.5px", + display: "flex", + position: "relative", + }, + + quotedMsgRight: { + padding: 10, + maxWidth: 300, + height: "auto", + whiteSpace: "pre-wrap", + }, + + quotedSideColorRight: { + flex: "none", + width: "4px", + backgroundColor: "#35cd96", + }, + + messageActionsButton: { + display: "none", + position: "relative", + color: "#999", + zIndex: 1, + backgroundColor: "inherit", + opacity: "90%", + "&:hover, &.Mui-focusVisible": { backgroundColor: "inherit" }, + }, + + messageContactName: { + display: "flex", + color: "#6bcbef", + fontWeight: 500, + }, + + textContentItem: { + overflowWrap: "break-word", + padding: "3px 80px 6px 6px", + }, + + textContentItemDeleted: { + fontStyle: "italic", + color: "rgba(0, 0, 0, 0.36)", + overflowWrap: "break-word", + padding: "3px 80px 6px 6px", + }, + + messageMedia: { + objectFit: "cover", + width: 250, + height: 200, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, + + timestamp: { + fontSize: 11, + position: "absolute", + bottom: 0, + right: 5, + color: "#999", + }, + + dailyTimestamp: { + alignItems: "center", + textAlign: "center", + alignSelf: "center", + width: "110px", + backgroundColor: "#e1f3fb", + margin: "10px", + borderRadius: "10px", + boxShadow: "0 1px 1px #b3b3b3", + }, + + dailyTimestampText: { + color: "#808888", + padding: 8, + alignSelf: "center", + marginLeft: "0px", + }, + + ackIcons: { + fontSize: 18, + verticalAlign: "middle", + marginLeft: 4, + }, + + deletedIcon: { + fontSize: 18, + verticalAlign: "middle", + marginRight: 4, + }, + + ackDoneAllIcon: { + color: green[500], + fontSize: 18, + verticalAlign: "middle", + marginLeft: 4, + }, + + downloadMedia: { + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "inherit", + padding: 10, + }, +})); + +const reducer = (state, action) => { + if (action.type === "LOAD_MESSAGES") { + const messages = action.payload; + const newMessages = []; + + messages.forEach((message) => { + const messageIndex = state.findIndex((m) => m.id === message.id); + if (messageIndex !== -1) { + state[messageIndex] = message; + } else { + newMessages.push(message); + } + }); + + return [...newMessages, ...state]; + } + + if (action.type === "ADD_MESSAGE") { + const newMessage = action.payload; + const messageIndex = state.findIndex((m) => m.id === newMessage.id); + + if (messageIndex !== -1) { + state[messageIndex] = newMessage; + } else { + state.push(newMessage); + } + + return [...state]; + } + + if (action.type === "UPDATE_MESSAGE") { + const messageToUpdate = action.payload; + const messageIndex = state.findIndex((m) => m.id === messageToUpdate.id); + + if (messageIndex !== -1) { + state[messageIndex] = messageToUpdate; + } + + return [...state]; + } + + if (action.type === "RESET") { + return []; + } +}; + +const MessagesList = ({ ticketId, isGroup }) => { + const classes = useStyles(); + + const [messagesList, dispatch] = useReducer(reducer, []); + const [pageNumber, setPageNumber] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const lastMessageRef = useRef(); + + const [selectedMessage, setSelectedMessage] = useState({}); + const [anchorEl, setAnchorEl] = useState(null); + const messageOptionsMenuOpen = Boolean(anchorEl); + const currentTicketId = useRef(ticketId); + + useEffect(() => { + dispatch({ type: "RESET" }); + setPageNumber(1); + + currentTicketId.current = ticketId; + }, [ticketId]); + + useEffect(() => { + setLoading(true); + const delayDebounceFn = setTimeout(() => { + const fetchMessages = async () => { + try { + const { data } = await api.get("/messages/" + ticketId, { + params: { pageNumber }, + }); + + if (currentTicketId.current === ticketId) { + dispatch({ type: "LOAD_MESSAGES", payload: data.messages }); + setHasMore(data.hasMore); + setLoading(false); + } + + if (pageNumber === 1 && data.messages.length > 1) { + scrollToBottom(); + } + } catch (err) { + setLoading(false); + toastError(err); + } + }; + fetchMessages(); + }, 500); + return () => { + clearTimeout(delayDebounceFn); + }; + }, [pageNumber, ticketId]); + + useEffect(() => { + const socket = openSocket(process.env.REACT_APP_BACKEND_URL); + + socket.on("connect", () => socket.emit("joinChatBox", ticketId)); + + socket.on("appMessage", (data) => { + if (data.action === "create") { + dispatch({ type: "ADD_MESSAGE", payload: data.message }); + scrollToBottom(); + } + + if (data.action === "update") { + dispatch({ type: "UPDATE_MESSAGE", payload: data.message }); + } + }); + + return () => { + socket.disconnect(); + }; + }, [ticketId]); + + const loadMore = () => { + setPageNumber((prevPageNumber) => prevPageNumber + 1); + }; + + const scrollToBottom = () => { + if (lastMessageRef.current) { + lastMessageRef.current.scrollIntoView({}); + } + }; + + const handleScroll = (e) => { + if (!hasMore) return; + const { scrollTop } = e.currentTarget; + + if (scrollTop === 0) { + document.getElementById("messagesList").scrollTop = 1; + } + + if (loading) { + return; + } + + if (scrollTop < 50) { + loadMore(); + } + }; + + const handleOpenMessageOptionsMenu = (e, message) => { + setAnchorEl(e.currentTarget); + setSelectedMessage(message); + }; + + const handleCloseMessageOptionsMenu = (e) => { + setAnchorEl(null); + }; + + const checkMessageMedia = (message) => { + if (message.mediaType === "image") { + return ; + } + if (message.mediaType === "audio") { + return ( + + ); + } + + if (message.mediaType === "video") { + return ( +