commit 43c3e1563facc634b5ee35dad0a1eeb3555ad95d Author: cheveguerra Date: Thu Feb 23 18:13:04 2023 -0600 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..375088a --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# MYSQL +MYSQL_ENGINE= +MYSQL_VERSION= +MYSQL_ROOT_PASSWORD= +MYSQL_DATABASE=whaticket +MYSQL_PORT= +TZ= + +# BACKEND +BACKEND_PORT= +BACKEND_SERVER_NAME=api.mydomain.com +BACKEND_URL=https://api.mydomain.com +PROXY_PORT=443 +JWT_SECRET= +JWT_REFRESH_SECRET= + +# FRONTEND +FRONTEND_PORT=80 +FRONTEND_SSL_PORT=443 +FRONTEND_SERVER_NAME=myapp.mydomain.com +FRONTEND_URL=https://myapp.mydomain.com + +# BROWSERLESS +MAX_CONCURRENT_SESSIONS= + +# PHPMYADMIN +PMA_PORT= \ No newline at end of file diff --git a/.fossa.yml b/.fossa.yml new file mode 100644 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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4e91794 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto +*.sh text eol=lf +Dockerfile eol=lf +docker-compose.yml eol=lf +docker-compose.*.yml eol=lf +*.jpg binary +*.png binary +*.gif binary +*.woff binary +*.tff binary +*.eot binary +*.otf binary \ No newline at end of file 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/.github/workflows/build-backend.yaml b/.github/workflows/build-backend.yaml new file mode 100644 index 0000000..4d22740 --- /dev/null +++ b/.github/workflows/build-backend.yaml @@ -0,0 +1,36 @@ +name: Test build backend + +on: + pull_request: + paths: + - "backend/**" + + push: + branches: + - master + paths: + - "backend/**" + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + working-directory: backend + run: npm install + + - run: npm run build + working-directory: backend diff --git a/.github/workflows/build-frontend.yaml b/.github/workflows/build-frontend.yaml new file mode 100644 index 0000000..1e35a2e --- /dev/null +++ b/.github/workflows/build-frontend.yaml @@ -0,0 +1,36 @@ +name: Test build frontend + +on: + pull_request: + paths: + - "frontend/**" + + push: + branches: + - master + paths: + - "frontend/**" + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + working-directory: frontend + run: npm install + + - run: CI=false npm run build + working-directory: frontend diff --git a/.github/workflows/push-image-backend.yaml b/.github/workflows/push-image-backend.yaml new file mode 100644 index 0000000..a140fb2 --- /dev/null +++ b/.github/workflows/push-image-backend.yaml @@ -0,0 +1,44 @@ +name: Create and publish a Backend Docker image + +on: + + push: + branches: [master] + paths: + - "backend/**" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: ./backend/ + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/push-image-frontend.yaml b/.github/workflows/push-image-frontend.yaml new file mode 100644 index 0000000..cab1b1e --- /dev/null +++ b/.github/workflows/push-image-frontend.yaml @@ -0,0 +1,44 @@ +name: Create and publish a Frontend Docker image + +on: + + push: + branches: [master] + paths: + - "frontend/**" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: ./frontend/ + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77a65a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.docker/data/ +ssl/ +.env +.wwebjs_auth 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..3e3795b --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# whaticket + Whaticket diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..8c6a991 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +.git +*Dockerfile* +*docker-compose* +node_modules +dist +.wwebjs_auth \ No newline at end of file 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/.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..167546d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,18 @@ +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/ + +.wwebjs_auth/ \ No newline at end of file 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/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0353bb5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,40 @@ +FROM node:14 as build-deps + +RUN apt-get update && apt-get install -y wget + +ENV DOCKERIZE_VERSION v0.6.1 +RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ + && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ + && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz + +RUN apt-get update \ + && apt-get install -y wget gnupg \ + && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ + && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ + && apt-get update \ + && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.1/dumb-init_1.2.1_amd64 /usr/local/bin/dumb-init +RUN chmod +x /usr/local/bin/dumb-init + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true + +WORKDIR /usr/src/app +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +ENV NODE_ENV=production +ENV PORT=3000 +ENV CHROME_BIN=google-chrome-stable + +EXPOSE 3000 + +ENTRYPOINT ["dumb-init", "--"] +CMD dockerize -wait tcp://${DB_HOST}:3306 \ + && npx sequelize db:migrate \ + && node dist/server.js \ No newline at end of file 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..0942d45 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,80 @@ +{ + "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", + "mustache": "^4.2.0", + "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", + "uuid": "^8.3.2", + "whatsapp-web.js": "^1.17.1", + "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/mustache": "^4.1.2", + "@types/node": "^14.11.8", + "@types/supertest": "^2.0.10", + "@types/uuid": "^8.3.3", + "@types/validator": "^13.1.0", + "@types/yup": "^0.29.8", + "@typescript-eslint/eslint-plugin": "^4.4.0", + "@typescript-eslint/parser": "^4.4.0", + "eslint": "^7.10.0", + "eslint-config-airbnb-base": "^14.2.0", + "eslint-config-prettier": "^6.12.0", + "eslint-import-resolver-typescript": "^2.3.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-prettier": "^3.1.4", + "factory-girl": "^5.0.4", + "faker": "^5.1.0", + "jest": "^26.6.0", + "nodemon": "^2.0.4", + "prettier": "^2.1.2", + "supertest": "^5.0.0", + "ts-jest": "^26.4.1", + "ts-node-dev": "^1.0.0-pre.63", + "typescript": "4.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/ApiController.ts b/backend/src/controllers/ApiController.ts new file mode 100644 index 0000000..66d72f1 --- /dev/null +++ b/backend/src/controllers/ApiController.ts @@ -0,0 +1,111 @@ +import { Request, Response } from "express"; +import * as Yup from "yup"; +import AppError from "../errors/AppError"; +import GetDefaultWhatsApp from "../helpers/GetDefaultWhatsApp"; +import SetTicketMessagesAsRead from "../helpers/SetTicketMessagesAsRead"; +import Message from "../models/Message"; +import Whatsapp from "../models/Whatsapp"; +import CreateOrUpdateContactService from "../services/ContactServices/CreateOrUpdateContactService"; +import FindOrCreateTicketService from "../services/TicketServices/FindOrCreateTicketService"; +import ShowTicketService from "../services/TicketServices/ShowTicketService"; +import CheckIsValidContact from "../services/WbotServices/CheckIsValidContact"; +import CheckContactNumber from "../services/WbotServices/CheckNumber"; +import GetProfilePicUrl from "../services/WbotServices/GetProfilePicUrl"; +import SendWhatsAppMedia from "../services/WbotServices/SendWhatsAppMedia"; +import SendWhatsAppMessage from "../services/WbotServices/SendWhatsAppMessage"; + +type WhatsappData = { + whatsappId: number; +} + +type MessageData = { + body: string; + fromMe: boolean; + read: boolean; + quotedMsg?: Message; +}; + +interface ContactData { + number: string; +} + +const createContact = async ( + whatsappId: number | undefined, + newContact: string +) => { + await CheckIsValidContact(newContact); + + const validNumber: any = await CheckContactNumber(newContact); + + const profilePicUrl = await GetProfilePicUrl(validNumber); + + const number = validNumber; + + const contactData = { + name: `${number}`, + number, + profilePicUrl, + isGroup: false + }; + + const contact = await CreateOrUpdateContactService(contactData); + + let whatsapp:Whatsapp | null; + + if(whatsappId === undefined) { + whatsapp = await GetDefaultWhatsApp(); + } else { + whatsapp = await Whatsapp.findByPk(whatsappId); + + if(whatsapp === null) { + throw new AppError(`whatsapp #${whatsappId} not found`); + } + } + + const createTicket = await FindOrCreateTicketService( + contact, + whatsapp.id, + 1 + ); + + const ticket = await ShowTicketService(createTicket.id); + + SetTicketMessagesAsRead(ticket); + + return ticket; +}; + +export const index = async (req: Request, res: Response): Promise => { + const newContact: ContactData = req.body; + const { whatsappId }: WhatsappData = req.body; + const { body, quotedMsg }: MessageData = req.body; + const medias = req.files as Express.Multer.File[]; + + newContact.number = newContact.number.replace("-", "").replace(" ", ""); + + const schema = Yup.object().shape({ + number: Yup.string() + .required() + .matches(/^\d+$/, "Invalid number format. Only numbers is allowed.") + }); + + try { + await schema.validate(newContact); + } catch (err: any) { + throw new AppError(err.message); + } + + const contactAndTicket = await createContact(whatsappId, newContact.number); + + if (medias) { + await Promise.all( + medias.map(async (media: Express.Multer.File) => { + await SendWhatsAppMedia({ body, media, ticket: contactAndTicket }); + }) + ); + } else { + await SendWhatsAppMessage({ body, ticket: contactAndTicket, quotedMsg }); + } + + return res.send(); +}; diff --git a/backend/src/controllers/ContactController.ts b/backend/src/controllers/ContactController.ts new file mode 100644 index 0000000..767863a --- /dev/null +++ b/backend/src/controllers/ContactController.ts @@ -0,0 +1,162 @@ +import * as Yup from "yup"; +import { Request, Response } from "express"; +import { getIO } from "../libs/socket"; + +import ListContactsService from "../services/ContactServices/ListContactsService"; +import CreateContactService from "../services/ContactServices/CreateContactService"; +import ShowContactService from "../services/ContactServices/ShowContactService"; +import UpdateContactService from "../services/ContactServices/UpdateContactService"; +import DeleteContactService from "../services/ContactServices/DeleteContactService"; + +import CheckContactNumber from "../services/WbotServices/CheckNumber" +import CheckIsValidContact from "../services/WbotServices/CheckIsValidContact"; +import GetProfilePicUrl from "../services/WbotServices/GetProfilePicUrl"; +import AppError from "../errors/AppError"; +import GetContactService from "../services/ContactServices/GetContactService"; + +type IndexQuery = { + searchParam: string; + pageNumber: string; +}; + +type IndexGetContactQuery = { + name: string; + number: string; +}; + +interface ExtraInfo { + name: string; + value: string; +} +interface ContactData { + name: string; + number: string; + email?: string; + 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 getContact = async (req: Request, res: Response): Promise => { + const { name, number } = req.body as IndexGetContactQuery; + + const contact = await GetContactService({ + name, + number + }); + + return res.status(200).json(contact); +}; + +export const store = async (req: Request, res: Response): Promise => { + 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..2b19890 --- /dev/null +++ b/backend/src/controllers/ImportPhoneContactsController.ts @@ -0,0 +1,9 @@ +import { Request, Response } from "express"; +import ImportContactsService from "../services/WbotServices/ImportContactsService"; + +export const store = async (req: Request, res: Response): Promise => { + const userId:number = parseInt(req.user.id); + await ImportContactsService(userId); + + 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..bacd4fb --- /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"; +import formatBody from "../helpers/Mustache"; + +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: formatBody(farewellMessage, ticket.contact), + 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..001d97b --- /dev/null +++ b/backend/src/controllers/UserController.ts @@ -0,0 +1,108 @@ +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, whatsappId } = 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, + whatsappId + }); + + 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/migrations/20220223095932-add-whatsapp-to-user.ts b/backend/src/database/migrations/20220223095932-add-whatsapp-to-user.ts new file mode 100644 index 0000000..fc53178 --- /dev/null +++ b/backend/src/database/migrations/20220223095932-add-whatsapp-to-user.ts @@ -0,0 +1,17 @@ +import { QueryInterface, DataTypes } from "sequelize"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.addColumn("Users", "whatsappId", { + type: DataTypes.INTEGER, + references: { model: "Whatsapps", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + allowNull: true + },); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.removeColumn("Users", "whatsappId"); + } +}; 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/database/seeds/20200904070006-create-apiToken-settings.ts b/backend/src/database/seeds/20200904070006-create-apiToken-settings.ts new file mode 100644 index 0000000..f5e2b36 --- /dev/null +++ b/backend/src/database/seeds/20200904070006-create-apiToken-settings.ts @@ -0,0 +1,23 @@ +import { QueryInterface } from "sequelize"; +import { v4 as uuidv4 } from "uuid"; + +module.exports = { + up: (queryInterface: QueryInterface) => { + return queryInterface.bulkInsert( + "Settings", + [ + { + key: "userApiToken", + value: uuidv4(), + createdAt: new Date(), + updatedAt: new Date() + } + ], + {} + ); + }, + + down: (queryInterface: QueryInterface) => { + return queryInterface.bulkDelete("Settings", {}); + } +}; 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..71de597 --- /dev/null +++ b/backend/src/helpers/CheckContactOpenTickets.ts @@ -0,0 +1,18 @@ +import { Op } from "sequelize"; +import AppError from "../errors/AppError"; +import Ticket from "../models/Ticket"; + +const CheckContactOpenTickets = async ( + contactId: number, + whatsappId: number +): Promise => { + const ticket = await Ticket.findOne({ + where: { contactId, whatsappId, 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..ef74cda --- /dev/null +++ b/backend/src/helpers/GetDefaultWhatsApp.ts @@ -0,0 +1,26 @@ +import AppError from "../errors/AppError"; +import Whatsapp from "../models/Whatsapp"; +import GetDefaultWhatsAppByUser from "./GetDefaultWhatsAppByUser"; + +const GetDefaultWhatsApp = async ( + userId?: number +): Promise => { + if(userId) { + const whatsappByUser = await GetDefaultWhatsAppByUser(userId); + if(whatsappByUser !== null) { + return whatsappByUser; + } + } + + 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/GetDefaultWhatsAppByUser.ts b/backend/src/helpers/GetDefaultWhatsAppByUser.ts new file mode 100644 index 0000000..e651777 --- /dev/null +++ b/backend/src/helpers/GetDefaultWhatsAppByUser.ts @@ -0,0 +1,20 @@ +import User from "../models/User"; +import Whatsapp from "../models/Whatsapp"; +import { logger } from "../utils/logger"; + +const GetDefaultWhatsAppByUser = async ( + userId: number +): Promise => { + const user = await User.findByPk(userId, {include: ["whatsapp"]}); + if( user === null ) { + return null; + } + + if(user.whatsapp !== null) { + logger.info(`Found whatsapp linked to user '${user.name}' is '${user.whatsapp.name}'.`); + } + + return user.whatsapp; +}; + +export default GetDefaultWhatsAppByUser; diff --git a/backend/src/helpers/GetTicketWbot.ts b/backend/src/helpers/GetTicketWbot.ts new file mode 100644 index 0000000..802e8e9 --- /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(ticket.user.id); + + 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/Mustache.ts b/backend/src/helpers/Mustache.ts new file mode 100644 index 0000000..92bd3de --- /dev/null +++ b/backend/src/helpers/Mustache.ts @@ -0,0 +1,9 @@ +import Mustache from "mustache"; +import Contact from "../models/Contact"; + +export default (body: string, contact: Contact): string => { + const view = { + name: contact ? contact.name : "" + }; + return Mustache.render(body, view); +}; 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..928f75b --- /dev/null +++ b/backend/src/helpers/SerializeUser.ts @@ -0,0 +1,23 @@ +import Queue from "../models/Queue"; +import User from "../models/User"; +import Whatsapp from "../models/Whatsapp"; + +interface SerializedUser { + id: number; + name: string; + email: string; + profile: string; + queues: Queue[]; + whatsapp: Whatsapp; +} + +export const SerializeUser = (user: User): SerializedUser => { + return { + id: user.id, + name: user.name, + email: user.email, + profile: user.profile, + queues: user.queues, + whatsapp: user.whatsapp + }; +}; 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..1b6794f --- /dev/null +++ b/backend/src/libs/wbot.ts @@ -0,0 +1,155 @@ +import qrCode from "qrcode-terminal"; +import { Client, LocalAuth } 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 args:String = process.env.CHROME_ARGS || ""; + + const wbot: Session = new Client({ + session: sessionCfg, + authStrategy: new LocalAuth({clientId: 'bd_'+whatsapp.id}), + puppeteer: { + executablePath: process.env.CHROME_BIN || undefined, + // @ts-ignore + browserWSEndpoint: process.env.CHROME_WS || undefined, + args: args.split(' ') + } + }); + + 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`); + }); + + 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); + } +}; \ No newline at end of file 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/middleware/isAuthApi.ts b/backend/src/middleware/isAuthApi.ts new file mode 100644 index 0000000..cc36460 --- /dev/null +++ b/backend/src/middleware/isAuthApi.ts @@ -0,0 +1,39 @@ +import { Request, Response, NextFunction } from "express"; + +import AppError from "../errors/AppError"; +import ListSettingByValueService from "../services/SettingServices/ListSettingByValueService"; + +const isAuthApi = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + const authHeader = req.headers.authorization; + + if (!authHeader) { + throw new AppError("ERR_SESSION_EXPIRED", 401); + } + + const [, token] = authHeader.split(" "); + + try { + const getToken = await ListSettingByValueService(token); + if (!getToken) { + throw new AppError("ERR_SESSION_EXPIRED", 401); + } + + if (getToken.value !== token) { + throw new AppError("ERR_SESSION_EXPIRED", 401); + } + } catch (err) { + console.log(err); + throw new AppError( + "Invalid token. We'll try to assign a new one on next request", + 403 + ); + } + + return next(); +}; + +export default isAuthApi; 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..73edff4 --- /dev/null +++ b/backend/src/models/User.ts @@ -0,0 +1,83 @@ +import { + Table, + Column, + CreatedAt, + UpdatedAt, + Model, + DataType, + BeforeCreate, + BeforeUpdate, + PrimaryKey, + AutoIncrement, + Default, + HasMany, + BelongsToMany, + ForeignKey, + BelongsTo +} from "sequelize-typescript"; +import { hash, compare } from "bcryptjs"; +import Ticket from "./Ticket"; +import Queue from "./Queue"; +import UserQueue from "./UserQueue"; +import Whatsapp from "./Whatsapp"; + +@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; + + @ForeignKey(() => Whatsapp) + @Column + whatsappId: number; + + @BelongsTo(() => Whatsapp) + whatsapp: Whatsapp; + + @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/apiRoutes.ts b/backend/src/routes/apiRoutes.ts new file mode 100644 index 0000000..e990052 --- /dev/null +++ b/backend/src/routes/apiRoutes.ts @@ -0,0 +1,14 @@ +import express from "express"; +import multer from "multer"; +import uploadConfig from "../config/upload"; + +import * as ApiController from "../controllers/ApiController"; +import isAuthApi from "../middleware/isAuthApi"; + +const upload = multer(uploadConfig); + +const ApiRoutes = express.Router(); + +ApiRoutes.post("/send", isAuthApi, upload.array("medias"), ApiController.index); + +export default ApiRoutes; 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..1e8ff2b --- /dev/null +++ b/backend/src/routes/contactRoutes.ts @@ -0,0 +1,27 @@ +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.post("/contact", isAuth, ContactController.getContact); + +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..e741c38 --- /dev/null +++ b/backend/src/routes/index.ts @@ -0,0 +1,29 @@ +import { Router } from "express"; + +import userRoutes from "./userRoutes"; +import authRoutes from "./authRoutes"; +import settingRoutes from "./settingRoutes"; +import contactRoutes from "./contactRoutes"; +import ticketRoutes from "./ticketRoutes"; +import whatsappRoutes from "./whatsappRoutes"; +import messageRoutes from "./messageRoutes"; +import whatsappSessionRoutes from "./whatsappSessionRoutes"; +import queueRoutes from "./queueRoutes"; +import quickAnswerRoutes from "./quickAnswerRoutes"; +import apiRoutes from "./apiRoutes"; + +const routes = Router(); + +routes.use(userRoutes); +routes.use("/auth", authRoutes); +routes.use(settingRoutes); +routes.use(contactRoutes); +routes.use(ticketRoutes); +routes.use(whatsappRoutes); +routes.use(messageRoutes); +routes.use(whatsappSessionRoutes); +routes.use(queueRoutes); +routes.use(quickAnswerRoutes); +routes.use("/api/messages", apiRoutes); + +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/GetContactService.ts b/backend/src/services/ContactServices/GetContactService.ts new file mode 100644 index 0000000..3865bc5 --- /dev/null +++ b/backend/src/services/ContactServices/GetContactService.ts @@ -0,0 +1,38 @@ +import AppError from "../../errors/AppError"; +import Contact from "../../models/Contact"; +import CreateContactService from "./CreateContactService"; + +interface ExtraInfo { + name: string; + value: string; +} + +interface Request { + name: string; + number: string; + email?: string; + profilePicUrl?: string; + extraInfo?: ExtraInfo[]; +} + +const GetContactService = async ({ name, number }: Request): Promise => { + const numberExists = await Contact.findOne({ + where: { number } + }); + + if (!numberExists) { + const contact = await CreateContactService({ + name, + number, + }) + + if (contact == null) + throw new AppError("CONTACT_NOT_FIND") + else + return contact + } + + return numberExists +}; + +export default GetContactService; \ No newline at end of file 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..2bfd9c7 --- /dev/null +++ b/backend/src/services/MessageServices/CreateMessageService.ts @@ -0,0 +1,66 @@ +import { getIO } from "../../libs/socket"; +import Message from "../../models/Message"; +import Ticket from "../../models/Ticket"; +import Whatsapp from "../../models/Whatsapp"; + +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: Whatsapp, + as: "whatsapp", + attributes: ["name"] + } + ] + }, + { + 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/ListSettingByValueService.ts b/backend/src/services/SettingServices/ListSettingByValueService.ts new file mode 100644 index 0000000..d914a9b --- /dev/null +++ b/backend/src/services/SettingServices/ListSettingByValueService.ts @@ -0,0 +1,22 @@ +import AppError from "../../errors/AppError"; +import Setting from "../../models/Setting"; + +interface Response { + key: string; + value: string; +} +const ListSettingByKeyService = async ( + value: string +): Promise => { + const settings = await Setting.findOne({ + where: { value } + }); + + if (!settings) { + throw new AppError("ERR_NO_API_TOKEN_FOUND", 404); + } + + return { key: settings.key, value: settings.value }; +}; + +export default ListSettingByKeyService; 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..bfda479 --- /dev/null +++ b/backend/src/services/TicketServices/CreateTicketService.ts @@ -0,0 +1,49 @@ +import AppError from "../../errors/AppError"; +import CheckContactOpenTickets from "../../helpers/CheckContactOpenTickets"; +import GetDefaultWhatsApp from "../../helpers/GetDefaultWhatsApp"; +import Ticket from "../../models/Ticket"; +import User from "../../models/User"; +import ShowContactService from "../ContactServices/ShowContactService"; + +interface Request { + contactId: number; + status: string; + userId: number; + queueId ?: number; +} + +const CreateTicketService = async ({ + contactId, + status, + userId, + queueId +}: Request): Promise => { + const defaultWhatsapp = await GetDefaultWhatsApp(userId); + + await CheckContactOpenTickets(contactId, defaultWhatsapp.id); + + const { isGroup } = await ShowContactService(contactId); + + if(queueId === undefined) { + const user = await User.findByPk(userId, { include: ["queues"]}); + queueId = user?.queues.length === 1 ? user.queues[0].id : undefined; + } + + const { id }: Ticket = await defaultWhatsapp.$create("ticket", { + contactId, + status, + isGroup, + userId, + queueId + }); + + 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..702f59c --- /dev/null +++ b/backend/src/services/TicketServices/FindOrCreateTicketService.ts @@ -0,0 +1,81 @@ +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, + whatsappId: whatsappId + } + }); + + if (ticket) { + await ticket.update({ unreadMessages }); + } + + if (!ticket && groupContact) { + ticket = await Ticket.findOne({ + where: { + contactId: groupContact.id, + whatsappId: whatsappId + }, + 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, + whatsappId: whatsappId + }, + 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..b8d8dd6 --- /dev/null +++ b/backend/src/services/TicketServices/ListTicketsService.ts @@ -0,0 +1,156 @@ +import { Op, fn, where, col, Filterable, Includeable } from "sequelize"; +import { startOfDay, endOfDay, parseISO } from "date-fns"; + +import Ticket from "../../models/Ticket"; +import Contact from "../../models/Contact"; +import Message from "../../models/Message"; +import Queue from "../../models/Queue"; +import ShowUserService from "../UserServices/ShowUserService"; +import Whatsapp from "../../models/Whatsapp"; + +interface Request { + searchParam?: string; + pageNumber?: string; + status?: string; + date?: string; + showAll?: string; + userId: string; + withUnreadMessages?: string; + queueIds: number[]; +} + +interface Response { + tickets: Ticket[]; + count: number; + hasMore: boolean; +} + +const ListTicketsService = async ({ + searchParam = "", + pageNumber = "1", + queueIds, + status, + date, + showAll, + userId, + withUnreadMessages +}: Request): Promise => { + 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"] + }, + { + model: Whatsapp, + as: "whatsapp", + attributes: ["name"] + } + ]; + + if (showAll === "true") { + whereCondition = { queueId: { [Op.or]: [queueIds, null] } }; + } + + if (status) { + whereCondition = { + ...whereCondition, + status + }; + } + + if (searchParam) { + const sanitizedSearchParam = searchParam.toLocaleLowerCase().trim(); + + includeCondition = [ + ...includeCondition, + { + model: Message, + as: "messages", + attributes: ["id", "body"], + where: { + body: where( + fn("LOWER", col("body")), + "LIKE", + `%${sanitizedSearchParam}%` + ) + }, + required: false, + duplicating: false + } + ]; + + whereCondition = { + ...whereCondition, + [Op.or]: [ + { + "$contact.name$": where( + fn("LOWER", col("contact.name")), + "LIKE", + `%${sanitizedSearchParam}%` + ) + }, + { "$contact.number$": { [Op.like]: `%${sanitizedSearchParam}%` } }, + { + "$message.body$": where( + fn("LOWER", col("body")), + "LIKE", + `%${sanitizedSearchParam}%` + ) + } + ] + }; + } + + if (date) { + whereCondition = { + createdAt: { + [Op.between]: [+startOfDay(parseISO(date)), +endOfDay(parseISO(date))] + } + }; + } + + if (withUnreadMessages === "true") { + const user = await ShowUserService(userId); + const userQueueIds = user.queues.map(queue => queue.id); + + whereCondition = { + [Op.or]: [{ userId }, { status: "pending" }], + queueId: { [Op.or]: [userQueueIds, null] }, + unreadMessages: { [Op.gt]: 0 } + }; + } + + const limit = 40; + const offset = limit * (+pageNumber - 1); + + const { count, rows: tickets } = await Ticket.findAndCountAll({ + where: whereCondition, + include: includeCondition, + distinct: true, + limit, + offset, + order: [["updatedAt", "DESC"]] + }); + + const hasMore = count > offset + tickets.length; + + return { + tickets, + count, + hasMore + }; +}; + +export default ListTicketsService; diff --git a/backend/src/services/TicketServices/ShowTicketService.ts b/backend/src/services/TicketServices/ShowTicketService.ts new file mode 100644 index 0000000..0644066 --- /dev/null +++ b/backend/src/services/TicketServices/ShowTicketService.ts @@ -0,0 +1,42 @@ +import Ticket from "../../models/Ticket"; +import AppError from "../../errors/AppError"; +import Contact from "../../models/Contact"; +import User from "../../models/User"; +import Queue from "../../models/Queue"; +import Whatsapp from "../../models/Whatsapp"; + +const ShowTicketService = async (id: string | number): Promise => { + 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"] + }, + { + model: Whatsapp, + as: "whatsapp", + attributes: ["name"] + } + ] + }); + + 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..8cac249 --- /dev/null +++ b/backend/src/services/TicketServices/UpdateTicketService.ts @@ -0,0 +1,84 @@ +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; + whatsappId?: 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, whatsappId } = ticketData; + + const ticket = await ShowTicketService(ticketId); + await SetTicketMessagesAsRead(ticket); + + if(whatsappId && ticket.whatsappId !== whatsappId) { + await CheckContactOpenTickets(ticket.contactId, whatsappId); + } + + const oldStatus = ticket.status; + const oldUserId = ticket.user?.id; + + if (oldStatus === "closed") { + await CheckContactOpenTickets(ticket.contact.id, ticket.whatsappId); + } + + await ticket.update({ + status, + queueId, + userId + }); + + + if(whatsappId) { + await ticket.update({ + whatsappId + }); + } + + 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..db1639b --- /dev/null +++ b/backend/src/services/UserServices/CreateUserService.ts @@ -0,0 +1,74 @@ +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; + whatsappId?: number; +} + +interface Response { + email: string; + name: string; + id: number; + profile: string; +} + +const CreateUserService = async ({ + email, + password, + name, + queueIds = [], + profile = "admin", + whatsappId +}: 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, + whatsappId: whatsappId ? whatsappId : null + }, + { include: ["queues", "whatsapp"] } + ); + + await user.$set("queues", queueIds); + + await user.reload(); + + return SerializeUser(user); +}; + +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..200ce05 --- /dev/null +++ b/backend/src/services/UserServices/ListUsersService.ts @@ -0,0 +1,57 @@ +import { Sequelize, Op } from "sequelize"; +import Queue from "../../models/Queue"; +import User from "../../models/User"; +import Whatsapp from "../../models/Whatsapp"; + +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"] }, + { model: Whatsapp, as: "whatsapp", attributes: ["id", "name"] }, + ] + }); + + 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..d98a8c0 --- /dev/null +++ b/backend/src/services/UserServices/ShowUserService.ts @@ -0,0 +1,22 @@ +import User from "../../models/User"; +import AppError from "../../errors/AppError"; +import Queue from "../../models/Queue"; +import Whatsapp from "../../models/Whatsapp"; + +const ShowUserService = async (id: string | number): Promise => { + const user = await User.findByPk(id, { + attributes: ["name", "id", "email", "profile", "tokenVersion", "whatsappId"], + include: [ + { model: Queue, as: "queues", attributes: ["id", "name", "color"] }, + { model: Whatsapp, as: "whatsapp", attributes: ["id", "name"] }, + ], + 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..44be3f4 --- /dev/null +++ b/backend/src/services/UserServices/UpdateUserService.ts @@ -0,0 +1,64 @@ +import * as Yup from "yup"; + +import AppError from "../../errors/AppError"; +import { SerializeUser } from "../../helpers/SerializeUser"; +import ShowUserService from "./ShowUserService"; + +interface UserData { + email?: string; + password?: string; + name?: string; + profile?: string; + queueIds?: number[]; + whatsappId?: 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 = [], whatsappId } = userData; + + try { + await schema.validate({ email, password, profile, name }); + } catch (err) { + throw new AppError(err.message); + } + + await user.update({ + email, + password, + profile, + name, + whatsappId: whatsappId ? whatsappId : null + }); + + await user.$set("queues", queueIds); + + await user.reload(); + + return SerializeUser(user); +}; + +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..531fff5 --- /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 (userId:number): Promise => { + const defaultWhatsapp = await GetDefaultWhatsApp(userId); + + 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..72760e9 --- /dev/null +++ b/backend/src/services/WbotServices/SendWhatsAppMedia.ts @@ -0,0 +1,47 @@ +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"; + +import formatBody from "../../helpers/Mustache"; + +interface Request { + media: Express.Multer.File; + ticket: Ticket; + body?: string; +} + +const SendWhatsAppMedia = async ({ + media, + ticket, + body +}: Request): Promise => { + try { + const wbot = await GetTicketWbot(ticket); + const hasBody = body + ? formatBody(body as string, ticket.contact) + : undefined; + + const newMedia = MessageMedia.fromFilePath(media.path); + const sentMessage = await wbot.sendMessage( + `${ticket.contact.number}@${ticket.isGroup ? "g" : "c"}.us`, + newMedia, + { + caption: hasBody, + sendAudioAsVoice: true + } + ); + + await ticket.update({ lastMessage: body || media.filename }); + + fs.unlinkSync(media.path); + + return sentMessage; + } catch (err) { + console.log(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..58328b8 --- /dev/null +++ b/backend/src/services/WbotServices/SendWhatsAppMessage.ts @@ -0,0 +1,47 @@ +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"; + +import formatBody from "../../helpers/Mustache"; + +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`, + formatBody(body, ticket.contact), + { + 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..0345c21 --- /dev/null +++ b/backend/src/services/WbotServices/wbotMessageListener.ts @@ -0,0 +1,441 @@ +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"; +import CreateContactService from "../ContactServices/CreateContactService"; +import GetContactService from "../ContactServices/GetContactService"; +import formatBody from "../../helpers/Mustache"; + +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 +) => { + + if (msg.type === 'location') + msg = prepareLocation(msg); + + 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.type === "location" ? msg.location.description ? "Localization - " + msg.location.description.split('\\n')[0] : "Localization" : msg.body }); + + await CreateMessageService({ messageData }); +}; + +const prepareLocation = (msg: WbotMessage): WbotMessage => { + let gmapsUrl = "https://maps.google.com/maps?q=" + msg.location.latitude + "%2C" + msg.location.longitude + "&z=17&hl=pt-BR"; + + msg.body = "data:image/png;base64," + msg.body + "|" + gmapsUrl; + + msg.body += "|" + (msg.location.description ? msg.location.description : (msg.location.latitude + ", " + msg.location.longitude)) + + return msg; +}; + +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 = formatBody(`\u200e${choosenQueue.greetingMessage}`, contact); + + 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 = formatBody(`\u200e${greetingMessage}\n${options}`, contact); + + 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 === "multi_vcard" || + msg.type === "sticker" || + msg.type === "location" + ) + 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 !== "location" && msg.type !== "chat" && msg.type !== "vcard" + //&& msg.type !== "multi_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 && + formatBody(whatsapp.farewellMessage, contact) === 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); + } + + if (msg.type === "vcard") { + try { + const array = msg.body.split("\n"); + const obj = []; + let contact = ""; + for (let index = 0; index < array.length; index++) { + const v = array[index]; + const values = v.split(":"); + for (let ind = 0; ind < values.length; ind++) { + if (values[ind].indexOf("+") !== -1) { + obj.push({ number: values[ind] }); + } + if (values[ind].indexOf("FN") !== -1) { + contact = values[ind + 1]; + } + } + } + for await (const ob of obj) { + const cont = await CreateContactService({ + name: contact, + number: ob.number.replace(/\D/g, "") + }); + } + } catch (error) { + console.log(error); + } + } + + /* if (msg.type === "multi_vcard") { + try { + const array = msg.vCards.toString().split("\n"); + let name = ""; + let number = ""; + const obj = []; + const conts = []; + for (let index = 0; index < array.length; index++) { + const v = array[index]; + const values = v.split(":"); + for (let ind = 0; ind < values.length; ind++) { + if (values[ind].indexOf("+") !== -1) { + number = values[ind]; + } + if (values[ind].indexOf("FN") !== -1) { + name = values[ind + 1]; + } + if (name !== "" && number !== "") { + obj.push({ + name, + number + }); + name = ""; + number = ""; + } + } + } + + // eslint-disable-next-line no-restricted-syntax + for await (const ob of obj) { + try { + const cont = await CreateContactService({ + name: ob.name, + number: ob.number.replace(/\D/g, "") + }); + conts.push({ + id: cont.id, + name: cont.name, + number: cont.number + }); + } catch (error) { + if (error.message === "ERR_DUPLICATED_CONTACT") { + const cont = await GetContactService({ + name: ob.name, + number: ob.number.replace(/\D/g, ""), + email: "" + }); + conts.push({ + id: cont.id, + name: cont.name, + number: cont.number + }); + } + } + } + msg.body = JSON.stringify(conts); + } catch (error) { + console.log(error); + } + } */ + } 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/docker-compose.browserless.yaml b/docker-compose.browserless.yaml new file mode 100644 index 0000000..5206683 --- /dev/null +++ b/docker-compose.browserless.yaml @@ -0,0 +1,17 @@ +version: '3' + +networks: + whaticket: + +services: + + backend: + environment: + - CHROME_WS=ws://chrome:3000 + + chrome: + image: browserless/chrome:latest + environment: + - MAX_CONCURRENT_SESSIONS=${MAX_CONCURRENT_SESSIONS:-1} + networks: + - whaticket \ No newline at end of file diff --git a/docker-compose.phpmyadmin.yaml b/docker-compose.phpmyadmin.yaml new file mode 100644 index 0000000..2ee6ec3 --- /dev/null +++ b/docker-compose.phpmyadmin.yaml @@ -0,0 +1,15 @@ +version: '3' + +networks: + whaticket: + +services: + + phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + environment: + - PMA_HOSTS=mysql + ports: + - ${PMA_PORT:-9000}:80 + networks: + - whaticket \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..3526645 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,62 @@ +version: '3' + +networks: + whaticket: + +services: + + backend: + build: + context: ./backend + dockerfile: ./Dockerfile + environment: + - DB_HOST=mysql + - DB_USER=root + - DB_PASS=${MYSQL_ROOT_PASSWORD:-strongpassword} + - DB_NAME=${MYSQL_DATABASE:-whaticket} + - JWT_SECRET=${JWT_SECRET:-3123123213123} + - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-75756756756} + - BACKEND_URL=${BACKEND_URL:-http://localhost} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000} + - PROXY_PORT=${PROXY_PORT:-8080} + - CHROME_ARGS=--no-sandbox --disable-setuid-sandbox + ports: + - ${BACKEND_PORT:-8080}:3000 + volumes: + - ./backend/public/:/usr/src/app/public/ + - ./backend/.wwebjs_auth/:/usr/src/app/.wwebjs_auth/ + networks: + - whaticket + + frontend: + ports: + - ${FRONTEND_PORT:-3000}:80 + - ${FRONTEND_SSL_PORT:-3001}:443 + build: + context: ./frontend + dockerfile: ./Dockerfile + environment: + - URL_BACKEND=backend:3000 + - REACT_APP_BACKEND_URL=${BACKEND_URL:-http://localhost}:${PROXY_PORT:-8080}/ + - FRONTEND_SERVER_NAME=${FRONTEND_SERVER_NAME} + - BACKEND_SERVER_NAME=${BACKEND_SERVER_NAME} + volumes: + - ./ssl/certs/:/etc/nginx/ssl/ + - ./ssl/www/:/var/www/letsencrypt/ + networks: + - whaticket + + mysql: + image: ${MYSQL_ENGINE:-mariadb}:${MYSQL_VERSION:-10.6} + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_bin + volumes: + - ./.docker/data/:/var/lib/mysql + environment: + - MYSQL_DATABASE=${MYSQL_DATABASE:-whaticket} + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-strongpassword} + - TZ=${TZ:-America/Fortaleza} + ports: + - ${MYSQL_PORT:-3306}:3306 + restart: always + networks: + - whaticket \ No newline at end of file diff --git a/frontend/.docker/add-env-vars.sh b/frontend/.docker/add-env-vars.sh new file mode 100644 index 0000000..e42efe0 --- /dev/null +++ b/frontend/.docker/add-env-vars.sh @@ -0,0 +1,33 @@ +_writeFrontendEnvVars() { + ENV_JSON="$(jq --compact-output --null-input 'env | with_entries(select(.key | startswith("REACT_APP_")))')" + ENV_JSON_ESCAPED="$(printf "%s" "${ENV_JSON}" | sed -e 's/[\&/]/\\&/g')" + sed -i "s/